System Design

Linearizable Reads in Multi-Region Systems

Building geographically distributed systems is no longer a luxury; it is a requirement. Whether it is for regulatory compliance, latency optimization, or disaster recovery, teams increasingly deploy their applications across multiple AWS regions or Google Cloud zones. However, moving data across regions introduces a fundamental challenge: maintaining strong consistency guarantees while keeping performance acceptable. In this post, we will explore how to implement linearizable reads using two of the leading managed database services: Google Cloud Spanner and Amazon DynamoDB.

Why Linearizability Matters

Linearizability is the strongest level of consistency model in the hierarchy. It ensures that every read returns the value written by the most recent completed write, and that every write is applied atomically across the system. In a multi-region context, achieving this is difficult because network partitions are common, and physical distance introduces significant latency. Without linearizability, users might see stale data after a write, leading to critical bugs in financial transactions, inventory management, or session states. For critical paths in your application, linearizable reads provide the safety net that eventual consistency models simply cannot.

Google Cloud Spanner: True Linearizability

Spanner was designed from the ground up to provide global-scale strong consistency. It uses a technique called "TrueTime" to handle clock uncertainty across multiple data centers. When you perform a read in Spanner, you can specify the isolation level to ensure linearizability. By default, Spanner reads are serializable, which is even stronger than linearizable, but you can tune your isolation levels for specific use cases.

from google.cloud import spanner

def get_account_balance(client, account_id):
    with client.snapshot(
        read_timestamp=None,  # Uses strong consistency by default
        staleness=None,
        min_read_timestamp=None,
        max_staleness=None
    ) as snapshot:
        sql = "SELECT balance FROM Accounts WHERE id = @id"
        params = {"id": account_id}
        param_types = {"id": spanner.param_types.INT64}
        result = snapshot.execute_sql(sql, params=params, param_types=param_types)
        for row in result:
            return row[0]

Notice the use of read_timestamp=None in the snapshot. This indicates a strong read, which waits for the clock to ensure the read is linearizable. While this introduces a small delay due to clock uncertainty, it guarantees that you never read a value that has been superseded by a newer write. For latency-sensitive reads where a slight delay is acceptable, Spanner’s architecture allows you to balance consistency and speed effectively.

Amazon DynamoDB: Achieving Linearizability with Conditional Reads

DynamoDB is primarily an eventually consistent store, but it offers a "strongly consistent" read option. By setting ConsistentRead=True in your read request, you ensure that the read reflects the most recent write. However, in a multi-region setup using DynamoDB Global Tables, achieving true linearizability requires careful handling of versioning and conditional updates.

DynamoDB Global Tables use a last-writer-wins conflict resolution strategy by default. To achieve linearizable behavior, you should implement a version vector or a logical clock in your application layer. When a write occurs, you increment a version number. When reading, you ensure that the read is consistent with the latest version across all regions.

import boto3

dynamodb = boto3.client('dynamodb', region_name='us-east-1')

def get_item_strongly_consistent(table_name, key):
    response = dynamodb.get_item(
        TableName=table_name,
        Key=key,
        ConsistentRead=True
    )
    return response.get('Item')

While ConsistentRead provides strong consistency within a region, cross-region linearizability requires additional logic. One practical pattern is to use a "read-after-write" token. After a write, the client receives a unique write ID. Subsequent reads can include this ID to ensure that they do not return stale data from a different region that has not yet synchronized the latest write. This approach mimics linearizability at the application level, ensuring that users always see the most recent state.

Practical Patterns for Implementation

When designing your system, consider the following patterns:

  • Hybrid Consistency: Use linearizable reads only for critical data paths, such as financial transactions or session authentication. For other data, such as logs or analytics, eventual consistency is sufficient and more cost-effective.
  • Read Replicas with Consistency Tokens: In DynamoDB, use conditional reads with version numbers to ensure that the read reflects the latest write. In Spanner, leverage the built-in consistency models to avoid manual versioning.
  • Latency Awareness: Linearizable reads introduce additional latency. Design your API to handle this gracefully, possibly by using optimistic UI updates where the client assumes success and reconciles with the server later.

Conclusion

Implementing linearizable reads in multi-region systems is a complex but achievable task. Google Cloud Spanner provides built-in support for strong consistency, making it a straightforward choice for teams that prioritize consistency above all else. Amazon DynamoDB offers flexibility through its strongly consistent reads and application-level versioning, allowing developers to tailor their consistency models to specific needs. By understanding the trade-offs and leveraging the right tools, you can build robust, multi-region systems that maintain data integrity without sacrificing performance. As your system scales, remember that consistency is a spectrum, and choosing the right level for each data path is key to a successful architecture.

Share: