0% прочитано

Laravel Analytics: Safe Additive Upserts Under Concurrent Traffic

ICanUp stores analytics history as daily aggregates. A read-increment-save cycle in PHP is unsafe when multiple requests arrive at the same time because increments can be lost. This article explains the lost-update problem, atomic additive writes, aggregate identity based on date and dimensions, and why the reporting day boundary must be consistent across the analytics system.

20 вересня 2026 р. 11 хв читанняLaravel

When I started persisting historical analytics in ICanUp, one of the first requirements looked trivial: every eligible view should increment the relevant counter by one.

For a single request, the obvious implementation is to read the current value, add 1 in PHP, and save it back.

But a web application does not process views strictly one at a time.

If two requests arrive at nearly the same moment, that simple sequence can lose one of the views without producing an error.

A counter can become logically incorrect even when both HTTP requests succeed and the database reports no error.
Laravel analytics concurrency diagram comparing unsafe read increment save with an atomic database increment into a daily aggregate
Two concurrent requests can read the same previous value and overwrite each other. An atomic database operation preserves both increments.

The Unsafe Pattern Looks Completely Reasonable

In simplified form, the code may look like this:

PHP - Read, increment, and save
$aggregate = AnalyticsDailyAggregate::query()    ->where(...)    ->first(); $aggregate->views++; $aggregate->save();

For one request, the result is correct.

The problem appears when two processes execute the sequence concurrently.

Two Requests Can Read the Same Old Value

One of two views is lost
Database: views = 100  Request A reads:100 Request B reads:100  Request A calculates:101 Request B calculates:

There were two new views, so the correct result should have been 102.

Both requests were working from the same previous value.

The second write does not add another increment. It simply writes 101 again.

A lost update is dangerous because the system may see no failure at all. It simply persists the wrong result.

The Database Should Perform the Increment

For this kind of counter, I need another property: the increment should happen as an atomic database operation against the current stored value.

SQL - Simplified atomic increment
UPDATE analytics_daily_aggregatesSET views = views + 1WHERE id = ?;

PHP no longer reads 100 in order to later write 101.

The database applies views = views + 1 directly to the current row value.

The Aggregate Row May Not Exist Yet

Daily analytics adds another condition.

The first view for a particular combination of dimensions on a given day may not have a row yet.

Both insertion and accumulation are required
First view today: aggregate row does not exist        |        vcreate row with views = 1  Next view: aggregate row already exists        |        vincrease views by 1

The required contract becomes: create the row if it does not exist, otherwise atomically increase the existing value.

This is where an upsert-style operation becomes useful, although the important property is atomicity rather than the method name.

A Simplified Additive Upsert

This SQL demonstrates the atomic accumulation principle. It is not a claim that the current ICanUp production implementation uses this exact SQL string.
SQL - Simplified additive write
INSERT INTO analytics_daily_aggregates (    aggregate_date,    metric,    subject_type,    subject_id,    locale,    views)VALUES (    ?, ?, ?, ?, ?, 1)ON DUPLICATE KEY UPDATE    views = views + 1;

The first request creates the row with 1.

Later requests do not perform a read-modify-save loop. They accumulate directly in the database.

The Database Must Know What One Aggregate Means

An atomic insert-or-update is only correct when the target row has an unambiguous identity.

A daily analytics aggregate is not identified by date alone.

Daily aggregate identity
aggregate identity date+ metric+ subject type+ optional subject identity+ locale+ selected dimensions

Different Dimensions Must Not Share One Bucket

For example, a Ukrainian and an English view of the same Post should not accidentally accumulate into one localized bucket when analytics needs a locale breakdown.

One content item with separate locale aggregates
2026-09-18Post 42locale = ukviews = 20  2026-09-18Post 42locale = enviews = 8

The same principle applies to other supported dimensions such as country, device type, browser family, operating system, traffic source, and other bounded values intentionally stored by the analytics system.

Dimensions Are Part of Aggregate Identity

If two requests have different dimension sets, they need to increment different aggregates.

Different dimensions produce different buckets
Request A:date    = 2026-09-18locale  = ukdevice  = desktop  Request B:date    = 2026-09-18locale  = ukdevice  = mobile  Result:two different aggregate buckets

Database Uniqueness Protects the Contract

If aggregate identity exists only in PHP, two concurrent requests can still both decide that the row does not exist yet.

The database schema should therefore help guarantee that one identity cannot create duplicate daily rows.

Uniqueness should be enforced by the database
same aggregate identity Request A -> INSERTRequest B -> INSERT without database uniqueness:two rows may appear with database uniqueness:one identity remains one bucket

Deduplicated Views Need Atomic Accumulation Too

ICanUp tracks regular views and deduplicated views separately.

Deduplication decides whether the second metric should be incremented for a request.

Once the decision to increment is made, however, the accumulation itself still needs to be safe under concurrent writes.

Different semantics, same atomicity requirement
eligible view    |    +--> views + 1 deduplication says "count"    |    +--> deduplicated_views + 1
Deduplicated views should not be interpreted as an exact count of unique people. It is a separate metric with its own deduplication semantics.

I Do Not Want to Lock the Whole Analytics Flow

The purpose of an atomic write is not to wrap every page view in a large manual lock.

The shorter the increment operation, the better it fits a frequently executed public request path.

I want the database to perform the smallest possible change against one aggregate bucket.

Daily Aggregates Need One Reporting-Day Boundary

Once the aggregate identity includes a date, another question becomes critical: when exactly does the next day begin?

One component cannot use the PHP process timezone, another the MySQL timezone, and a third UTC by accident.

Reporting-day boundary
23:59:59 Europe/Kyiv        |        v2026-09-18  00:00:00 Europe/Kyiv        |        v2026-09-19

The aggregate date needs to be derived deterministically from one reporting-time contract. In the current architecture, this is tied to Europe/Kyiv.

Server Timezone Should Not Accidentally Define Analytics

A server may run in UTC. A queue worker or another process may also have different environment settings.

If code simply asks for "today" without an explicit contract, views around midnight can land in the wrong reporting day.

PHP - Derive the reporting date explicitly
$reportingDate = $occurredAt    ->setTimezone(        new DateTimeZone('Europe/Kyiv')    )    ->format('Y-m-d');
The exact syntax is less important than having one rule everywhere analytics writes or reads daily data.

Do Not Hardcode Summer or Winter UTC Offsets

I do not want logic such as "Kyiv is always UTC+3".

A named timezone allows the standard time library to apply daylight-saving rules correctly.

Named timezone instead of fixed offset
Use:Europe/Kyiv Do not hardcode:UTC+2 foreverUTC+3 forever

Writes and Reads Must Agree on the Reporting Day

Correct day calculation during writes is not enough.

Today filters, daily charts, and period comparisons need to use the same reporting-time contract.

One day boundary for writing and querying
Tracking:timestamp -> Europe/Kyiv -> aggregate date Admin Analytics:selected period -> Europe/Kyiv day boundaries Both must agree

Why I Store Aggregates Instead of Every Raw View

For this analytics problem, I do not need an unlimited store of every HTTP request.

The current questions can be answered efficiently through accumulated values per day and bounded dimensions.

Raw events

Daily aggregates

One row per view

One row per dimension combination

Storage grows quickly

Storage growth is more controlled

Keeps detailed event history

Fits totals and time-series queries well

Can retain excessive data

Makes a privacy-safe contract easier

Aggregates Make the Identity Contract More Important

Without raw events, an incorrect aggregate cannot simply be reconstructed later from a complete event history.

Three properties therefore become especially important:

  • correct aggregate identity;
  • atomic increments;
  • deterministic reporting date.

Every New Dimension Increases Aggregate Cardinality

Every additional dimension splits data into smaller groups.

That is useful, but it has a cost.

Every combination may require its own bucket
date+ locale+ country+ device+ browser+ OS+ traffic source+ subject...

This is why I keep analytics dimensions bounded and normalized rather than storing arbitrary request values.

For country analytics, for example, a normalized country code is sufficient. Raw IP is not needed.

Unknown Dimension Values Need One Representation

When a dimension is unknown, its representation also needs to be consistent during identity construction.

Otherwise semantically identical requests can end up in different buckets because one uses null, another an empty string, and another unknown.

Normalize unknown dimension values
Bad: country = nullcountry = ""country = "unknown" Three representationsof the same semantic state

Retry Semantics Are a Separate Problem

Atomic accumulation protects against concurrent overwrites, but it does not solve every analytics problem automatically.

If the same logical view is accidentally processed twice, the database will correctly increment the counter twice.

Concurrency safety and retry idempotency are separate concerns.

Problem

What addresses it

Two legitimate views arrive concurrently

Atomic accumulation

One logical view is processed twice

A separate deduplication or idempotency contract

Cache Is Not the Historical Source of Analytics

Another architectural decision is that historical metrics live in persistent database storage.

Cache may optimize reads, but clearing it must not erase view history.

Database storage keeps the history
Public request    |    vAnalytics persistence    |    vDatabase history Cache:optional read optimizationnot historical storage

Tests Need to Protect the Concurrency Contract

A test that sequentially records two views and receives 2 does not prove that the implementation is safe under concurrency.

The persistence contract should also protect aggregate uniqueness and avoid a read-modify-save counter implementation.

  • the first write creates an aggregate;
  • the next write increments the same aggregate;
  • different dimension sets create different aggregates;
  • one identity does not create duplicate daily rows;
  • views and deduplicated views accumulate independently;
  • events around the reporting-day boundary land on different dates;
  • UK and EN remain separate when locale is a dimension;
  • clearing cache does not delete historical metrics.

Midnight Is an Important Boundary Test

Reporting-day boundary test
2026-09-18 23:59:59 Europe/Kyiv-> aggregate date 2026-09-18 2026-09-19 00:00:00 Europe/Kyiv-> aggregate date 2026-09-19

This is a small test that protects the entire daily chart from a very subtle midnight shift.

What I Avoid in Analytics Counters

  • Do not read the current views value into PHP just to write views + 1 back.
  • Do not rely only on an application-level "does the row exist?" check before INSERT.
  • Do not define aggregate uniqueness only in application code.
  • Do not let a process timezone accidentally define reporting dates.
  • Do not merge different dimension sets into the same aggregate.
  • Do not persist arbitrary unbounded values as analytics dimensions without a reason.
  • Do not call deduplicated views an exact count of unique people.
  • Do not use cache as the only historical store.
  • Do not treat atomic increments as automatic protection from processing the same logical event twice.

My Short Checklist for Additive Analytics

  • Define the exact identity of one aggregate first.
  • Protect that identity with a database constraint.
  • Avoid read -> increment -> save for a concurrent counter.
  • Increment values atomically in the database.
  • Support first-row creation and existing-row accumulation through one safe contract.
  • Normalize dimension values before building the identity.
  • Use one reporting timezone.
  • Use the same day boundaries for writes and reads.
  • Treat concurrency, deduplication, and retry idempotency as separate concerns.
  • Test day boundaries and multiple dimension combinations.
  • Keep history in persistent storage and treat cache only as an optimization.

A counter is not correct merely because every request knows how to add one. It is correct when two ones added at the same time can never collapse into one.

Conclusion

In ICanUp daily analytics, the main problem was not incrementing views itself. It was guaranteeing that concurrent requests could not overwrite each other.

A read -> add 1 -> save cycle works sequentially but can silently lose updates under concurrency.

Accumulation therefore needs to happen atomically in the database, while each daily aggregate is identified by a stable combination of date, metric, subject type, optional subject identity, and the required dimensions.

The reporting date is equally important. If different parts of the application disagree about when a day begins, even perfectly atomic counters will produce an incorrect daily chart. Writes and reads therefore need one reporting-time contract.

My main lesson is that an analytics counter has to be correct not only for one request, but also when many requests try to change it at the same time.