Migrating Counter Service storage: Design choices and learnings
Grab migrated its fraud Counter Service from a wide-column database to Aerospike, achieving 50% lower latency and costs by switching to a map-based data model.
Deep dive
- The original system required four network round-trips per counter event; the new system uses a single atomic MapIncrementOp.
- A storage facade using enum dispatch allowed the Rust service to switch backends at runtime without heap allocation overhead.
- Shadow reads enabled parity verification before shifting live traffic.
- Using Aerospike's sorted maps reduced disk usage from 3TB to 1TB and primary index memory usage significantly.
- The team reduced the replication factor from 3 to 2, relying on Aerospike's master-replica model for fault tolerance.
Decoder
- Wide-column database: A type of NoSQL database that stores data in tables, rows, and dynamic columns (e.g., Cassandra or ScyllaDB).
- Shadow read: Running a query against both old and new databases simultaneously to verify consistency without returning the shadow result to the user.
- Read-modify-write: A pattern where a service reads a value, updates it in application logic, and writes it back, which is vulnerable to race conditions.
Original article
Introduction
Counter Service is used across Grab’s anti-fraud platform to answer time-windowed count questions, such as recent ride requests by a user or failed payment attempts on a card. The service handles tens of thousands of queries per second (QPS) with about a billion requests per day, while maintaining strict requirements around latency and reliability to support real-time fraud rule evaluation.
For most of its life, Counter Service was backed by a wide-column database that served the workload reliably as the service scaled. As part of a broader infrastructure review mandated at an organizational level, our database team evaluated alternatives to this storage that many services relied on, including Counter Service. Based on their assessment, Aerospike emerged as a good fit for our use-case. We also used the migration as an opportunity to decouple storage concerns from business logic, a necessary first step for this migration, and one that would reduce the effort required for future storage changes. As part of the same effort, we revisited the data model and access patterns in detail, which helped us identify and apply several straightforward optimizations.
This post walks through how we did it. What we built on the reader-side to make the migration safe, how we redesigned the writer-side data model around the new backend, and what we ran into during the gradual rollout.
Setting the stage
Counter data is stored in three time granularities: 15-minute, hourly, and daily buckets. A typical read would be along the lines of, “give me the count for key X over the last 90 minutes”, which the service decomposes into the smallest possible set of buckets, one hourly in the middle, a few 15-minute buckets at the edges, fetches them, and sums.
In the original setup, each granularity was stored in a separate table with a composite primary key:
TABLE daily_count (
key TEXT, -- partition key
day_ts TIMESTAMP, -- clustering key
count BIGINT,
PRIMARY KEY (key, day_ts)
);
The clustering column gave us convenient range queries, that is needed for the Counter Service. On the write path, each incoming counter event triggered a read-modify-write, three parallel SELECT across the three tables, an in-memory increment, then a batch write. This produced four network round-trips per event.
As this service is a core part of Grab’s fraud detection ecosystem and handles high query volume, migrating its underlying storage required a careful rollout plan. We had three requirements:
- Ramp traffic to the new backend gradually and roll back at any point with a config change.
- Monitor both the original and new storage paths to verify data integrity before switching over.
- Complete the migration without downtime.
Reader: Separating the data access layer
The reader is a Rust service. Before any migration work began, the reader’s business logic had tight coupling with the storage layer. Session creation, query building, fan-out orchestration, and the data types those queries returned were all intertwined in a single flat file. The main application state struct (AppState) held a raw database session handle and prepared query references. Every handler, gRPC Remote Procedure Calls (gRPC) or HyperText Transfer Protocol (HTTP), received the bare session as a parameter. Variable names baked the storage technology into the business layer.
Stage 1: Extracting the storage code
The first stage shipped no behavioural change. We deleted the monolithic storage file and split its contents in two:
storage/legacy.rs: wrapped session creation, prepared statements, and query execution behind aLegacyStoragestruct.batch_read_ops.rs: kept only the orchestration logic: time-range splitting, channel-based fan-out, and aggregation.
AppState started holding an Arc<LegacyStorage> instead of a raw session handle. The PreparedQueries struct lost its statements (those moved inside LegacyStorage). We renamed every storage-specific identifier in business code to generic storage_* names.
Stage 2: The storage facade
With the seam in place, we introduced the actual abstraction. A new storage/ module with mod.rs, legacy.rs, aerospike.rs, and mock_storage.rs as siblings became the only place driver crates were reachable from.
We chose a concrete facade with enum dispatch:
struct Storage {
legacy: LegacyStorage,
aerospike: AerospikeStorage,
mock: MockStorage,
settings: StorageSettings,
}
execute_queries(backend: BackendType, ...) {
match backend {
Legacy => self.legacy.execute(...),
Aerospike => self.aerospike.execute(...),
Mock => self.mock.execute(...),
}
}
On top of the facade we layered three config-driven operating modes that map to the migration phases:
- Single: one backend serves the request.
- WithShadow: the primary serves the response; the secondary runs asynchronously in the background for parity comparison.
- WithSplit: a deterministic percentage of traffic is served by each backend. Used for the live cutover.
Stage 3: Shadow comparison and metrics
Each storage call carries metadata like backend, role (primary/secondary/shadow), and mode, attached as tags to every metric. We placed the mode dispatch at the handler level rather than inside the storage layer to validate the full request path, not only the rows returned by storage.
Writer: redesigning the data model
Approach 3: Map-based schema
The third approach was structurally different from the first two and was the most compact of the options. Rather than storing one record per bucket, which kept us in the same cardinality regime, we collapsed all bucket counts for a single counter into one record. The values were stored as a sorted map keyed by bucket timestamp:
Set: helium_hourly
Primary key: "{counterKey}"
Bins:
counts: KEY_ORDERED_MAP({
1773369000000: 1,
1773372600000: 3,
1773376200000: 7,
...
})
Writes use MapIncrementOp, an atomic server-side increment of a value at a given map key, creating the entry on first access. Combined with MapRemoveByKeyRangeOp for pruning stale entries, every write is one atomic operation.
Reader: How each backend actually serves a query
The reader takes a batch of counter queries and decomposes each into one or more sub-queries per granularity. In the original backend, each sub-query becomes its own prepared statement. On Aerospike, the storage layer first groups all sub-queries by granularity, then issues one BatchOperate per granularity. Each sub-query becomes a single primary-key read against the appropriate set; the server returns the entire counts map for that key in one record.
Experiment with indexing
We run Aerospike in its default storage configuration, Hybrid Memory Architecture (HMA), where the primary index sits in Random-Access Memory (RAM) and the data sits on Solid-State Drive (SSD). To raise the memory ceiling, we tried moving the primary index itself from RAM to local Non-Volatile Memory Express (NVMe) while keeping data on SSD. In practice, we started seeing p99 spikes that followed I/O activity on hot keys. Consequently, we reverted back to in-memory index with a memory-optimized instance type.
Overall impact
The migration delivered gains across infrastructure, performance, and data footprint. The primary index currently uses about 50 GB of the roughly 100 GB usable memory per node. The same dataset is around 1 TB on disk, compared with around 3 TB on the original setup. In production, p99 read latency was consistently better than the original setup, with roughly 50% improvement across our read paths. The write path now uses a single atomic increment operation, replacing the read-modify-write pattern we had built previously. The new setup costs roughly 45–50% less per node compared to our original setup.
Conclusion
This migration ultimately came down to aligning the storage design with the workload. The gains came from focusing on optimization opportunities, redesigning the data model, and cleanly separating storage concerns. Through shadow reads and writes, followed by a gradual rollout, we completed the migration with zero downtime and no data-integrity issues.
Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi