Spanner SQL: From Key-Value to Relational Database
Evolution
- Spanner started as a key-value store (2012)
- Multi-row transactions
- External consistency
- Transparent failover across datacenters
- Evolved into full SQL relational database system (2012-2017)
- Strongly-typed schema system
- SQL query processor
- Initially “bolted on” but became tightly integrated
Key Design Philosophy
- Storage architecture drives query execution design
- Query processor demands drive storage evolution
- Preserve massive scalability while offering full SQL
- Hide transient failures completely from users
Architecture Overview
- Geo-replicated, sharded relational database
- Horizontal row-range sharding within datacenters
- Shards replicated across geographically separated datacenters
- Paxos consensus for replication (see Paxos notes)
- External consistency via TrueTime
- Snapshot isolation for reads (see isolation notes)
- Two-phase locking (2PL) + two-phase commit (2PC) for transactions
- LSM-tree based storage (evolved from SSTables to Ressi)
Table Interleaving
- Child tables co-located with parent tables
- Rows with matching key prefixes stored together
- Example:
Parent(key, name) Child(parent_key, child_key, value) INTERLEAVE IN PARENT Storage layout: Parent(K1) → [Child(K1,C1), Child(K1,C2), Child(K1,C3)] Parent(K2) → [Child(K2,C1), Child(K2,C2)] - Shard boundaries preserve co-location
Background: Database Query Processing Basics
Relational Algebra
- Mathematical foundation for query processing
- Set of operations on relations (tables)
- Basic operators:
- Scan: Read rows from table
- Filter (σ): Select rows matching condition
- Project (π): Select specific columns
- Join (⋈): Combine rows from two tables
- GroupBy/Aggregate: Group rows and compute aggregates
- Sort: Order rows by column values
- Union/Intersection/Difference: Set operations
Relational Algebra Tree
- Tree representation of query execution plan
- Leaves: Base table scans
- Internal nodes: Operators (joins, filters, etc.)
- Root: Final result
- Example:
Query: SELECT name FROM Customer WHERE age > 30 Relational Algebra Tree: Project(name) ↓ Filter(age > 30) ↓ Scan(Customer) Execution: bottom-up 1. Scan Customer table 2. Filter rows where age > 30 3. Project only 'name' column
Logical vs Physical Plans
Logical Plan
- Abstract representation using relational algebra
- Describes WHAT to compute
- Independent of execution strategy
- Example:
Join(Customer, Orders)
Physical Plan
- Concrete execution strategy
- Describes HOW to compute
- Specifies algorithms and data movement
- Example:
HashJoin(Customer, Orders)orNestedLoopJoin(Customer, Orders)
Logical Plan: Physical Plan:
Join HashJoin
/ \ / \
Customer Orders Scan(Customer) Scan(Orders)
↓ ↓
Build Hash Probe Hash
Query Compilation Pipeline
SQL Text
↓
Parsing → Abstract Syntax Tree (AST)
↓
Semantic Analysis → Resolved AST (with types, schema)
↓
Logical Optimization → Logical Plan
↓
Physical Planning → Physical Plan
↓
Code Generation → Executable Query Plan
↓
Execution
Common Join Operators
CrossApply (Correlated Join)
- For each row from left input, evaluate right expression
- Right side can reference columns from left row
- Concatenates all results
- Example:
CrossApply( Scan(Customer), λc. Scan(Orders WHERE customer_id = c.id) ) For each customer c: Find all orders for c Output: (c.name, c.id, order.id, order.amount)
OuterApply
- Like CrossApply but emits row even if right side is empty
- Similar to LEFT OUTER JOIN
- Example:
OuterApply( Scan(Customer), λc. Scan(Orders WHERE customer_id = c.id) ) Customer with no orders → (c.name, c.id, NULL, NULL)
Nested Loop Join
- Simple join algorithm
- For each row in outer table, scan inner table
- Inefficient but works for any join condition
for each row r1 in Table1: for each row r2 in Table2: if join_condition(r1, r2): output (r1, r2)
Hash Join
- Build hash table on one input
- Probe with other input
- Efficient for equi-joins ``` Build phase: for each row r in Table1: hash_table[r.key] = r
Probe phase: for each row s in Table2: if s.key in hash_table: output (hash_table[s.key], s)
### Query Optimization Concepts
#### Predicate Pushdown
* Move filters closer to data sources
* Reduces data movement
Before: After:
Filter(age > 30) Join
↓ /
Join Filter(age>30) Orders
/ \ ↓
Customer Orders Scan(Customer)
Fewer rows sent to join!
#### Projection Pushdown
* Select only needed columns early
* Reduces data size in pipeline
#### Transformation Rules
* Equivalence rules for rewriting plans
* Examples:
* Filter(A AND B) = Filter(A) ∘ Filter(B)
* Join is commutative: R ⋈ S = S ⋈ R
* Join is associative: (R ⋈ S) ⋈ T = R ⋈ (S ⋈ T)
### Distributed Query Execution Basics
#### Data Partitioning
* Horizontal partitioning: Split rows across servers
* Range partitioning: Rows with keys in [a,b) on server 1
* Hash partitioning: Hash(key) mod N determines server
#### Distributed Operators
* **Partition**: Route data to servers based on key
* **Gather**: Collect data from multiple servers
* **Broadcast**: Send data to all servers
* **Shuffle**: Redistribute data (e.g., for distributed join)
#### Example: Distributed GroupBy
Query: SELECT dept, SUM(salary) FROM Employee GROUP BY dept
If data partitioned by employee_id (not dept):
Plan:
-
Local partial aggregation (each server) GroupBy(dept) → partial sums
-
Shuffle by dept Partition data so same dept goes to same server
-
Final aggregation Merge partial sums per dept ```
Spanner-Specific Concepts
Sharding
- Table split into contiguous key ranges
- Each shard = one key range
- Example: Table with keys 1-1000
- Shard 1: [1, 250)
- Shard 2: [250, 500)
- Shard 3: [500, 750)
- Shard 4: [750, 1000]
Coprocessor Framework
- RPC addressed to key/key range, not server
- Framework determines:
- Which Paxos group owns the data
- Which replica to use (nearest, most up-to-date)
- Transparently handles:
- Data movement (resharding)
- Server failures
- Network issues
Distribution vs Execution
- Distribution: Decide which servers to query
- Execution: Run query logic on each server
- Distribution operators: DistributedUnion, DistributedApply
- Execution operators: Scan, Filter, Join, etc.
Query Distribution
From Logic to Physical Plan
Distributed Union Operator
- Core building block for distributed execution
- Inserted above every table scan in relational algebra
- Transforms global scan into distributed local scans:
Scan(T) ⇒ DistributedUnion[shard ⊆ T](Scan(shard))
Partitionability Property
- Key requirement for pushing operations down to shards
- For operation F to be pushed down:
F(Scan(T)) = OrderedUnionAll[shard ⊆ T](F(Scan(shard))) - Operations that can be pushed down:
- Projection and filtering (always)
- Sorting/GroupBy when sharding columns are prefix
- Joins between interleaved tables
Multi-Stage Processing
- Operations pushed down even when not fully partitionable
- Example: Top-K query
Top(N) ⇒ TopFinal(DistributedUnion[shard](TopLocal(N, shard))) - Each shard computes local Top N
- Final stage merges results
Example Query Plan
Query: SELECT ANY_VALUE(c.name), SUM(s.amount) total
FROM Customer c JOIN Sales s ON c.ckey=s.ckey
WHERE s.type='global' AND c.ckey IN UNNEST(@array)
GROUP BY c.ckey ORDER BY total DESC LIMIT 5
Initial Plan:
Top(5)
↓
GroupBy(ckey)
↓
CrossApply (Join)
/ \
Scan(Customer) Scan(Sales)
After Distribution Optimization:
Top(5) ← final
↓
DistributedUnion
↓
Top(5) ← local per shard
↓
GroupBy(ckey) ← pushed to shards
↓
CrossApply
/ \
Scan(Customer) Scan(Sales) ← executed locally on each shard
Filter: ckey IN @array
Distributed Execution
Shard Pruning
- Extract sharding key ranges from query predicates
- Only query relevant shards
- Example:
WHERE ckey IN (1, 5, 10)→ query only 3 shards
Parallel Execution
- Dispatch subqueries to shards in parallel
- Use coprocessor framework to route to nearest replica
- Adjustable parallelism for load isolation
Batching Strategy
- Single call per server (hosting multiple shards)
- vs. one call per shard
- Minimizes cross-machine calls
Shard Affinity Optimization
- Detect when shards are co-located locally
- Avoid remote calls for small databases
- Important for queries on shared sharding keys (e.g., User object)
Distributed Joins
Batched Apply Join
- Problem: Naive apply join = 1 RPC per row (too expensive!)
- Solution: Batch rows before remote calls
Transformation:
CrossApply(<input>, <map>)
↓
DistributedApply
↓
Batch(<input>) → $batch
↓
DistributedUnion
↓
Unnest($batch) → CrossApply(<row>, <map>)
- Example use cases:
- Join secondary index with base table
- Inner/Left/Semi-joins on remote table keys
- Turn full table scans into minimal range scans
Batched Apply Shard Pruning
- Evaluate sharding key filter for each row in batch
- Merge sharding key ranges across all rows
- Compute minimal set of shards
- Construct minimal batch per shard
- Execute in parallel on relevant shards
Query Distribution APIs
Single-Consumer API
- Query sent to root server
- Location hint extraction:
- Analyze outermost distribution operator
- Extract pattern for sharding keys from query parameters
- Cache on client for subsequent executions
- Direct routing to relevant server
- Example:
SELECT * FROM Parent WHERE key = @t→ Sent directly to server owning shard with key=@t
Parallel-Consumer API
- For large-scale data processing pipelines
- Two-stage process:
- Partition query into N parts (returns partition descriptors)
- Execute each partition from separate machines in parallel
- Requires root-partitionable queries
- Must have DistributedUnion at root after compilation
- Example:
Table Parent with 10 shards [0,100), [100,200), ..., [900,1000) Request 5-way parallelism → Returns 5 descriptors, each covering 2 shards → 5 clients execute in parallel, each queries 2 shards
Query Range Extraction
Three Types of Range Extraction
1. Distribution Range Extraction
- Determines which shards to query
- Uses sharding key prefix (e.g., ProjectId)
- Routes query to correct servers
2. Seek Range Extraction
- Determines what data to read from storage
- Uses full or longer key prefix
- Converts full-shard scans to targeted seeks
- Trade-off: cost of extraction vs. benefit of smaller reads
3. Lock Range Extraction
- Determines what ranges to lock for transactions
- Fine-grained locks improve concurrency
- Trade-off: many fine locks vs. few coarse locks
- Spanner supports column-level locking
Example
Table Documents(ProjectId, DocumentPath, Timestamp)
Sharding key: ProjectId
Query:
SELECT * FROM Documents
WHERE ProjectId = 'P001'
AND STARTS_WITH(DocumentPath, '/proposals')
AND Version = '2017-01-01'
Distribution ranges: (P001)
Seek ranges: (P001, /proposals/doc1, 2017-01-01),
(P001, /proposals/doc2, 2017-01-01), ...
Lock ranges: Key columns: (P001, /proposals)
Non-key columns: Same as seek ranges
Compile-Time Rewriting
Correlated Self-Joins
- Transform filtered scan into tree of correlated scans
- One scan per key column
- Extract ranges for successive key columns
Original:
Scan(Documents)
Filter: ProjectId=@p AND STARTS_WITH(path,'/proposals') AND Version=@v
Rewritten:
CrossApply
/ \
CrossApply Scan3(Documents.*)
/ \ Filter: ProjectId=@pid AND
Scan1 Scan2 DocumentPath=@dpath AND Version=@v
(ProjectId) (DocumentPath)
Filter: Filter:
ProjectId ProjectId=@pid AND
=@p STARTS_WITH(path,'/proposals')
Outputs: Outputs:
@pid @dpath
Expression Normalization
- Push NOT to leaf predicates
- Isolate key column references
1 > kbecomesk < 1NOT(k > 1)becomesk <= 1
- Discretize small integer intervals
k BETWEEN 5 AND 7→ explicit enumeration: 5, 6, 7k BETWEEN 5 AND 7000→ non-enumerable, requires table read
- Eliminate complex conditions (subqueries, expensive functions)
Post-Filtering
- Rewritten plan may not fully absorb original filter
- Add post-filter for conditions not expressible as seeks
- Example:
(k1=1 AND k2="a" AND REGEXP_MATCH(value, @param)) OR (k1=2 AND k2<"b") Rewritten plan: - Seek to (1, "a") + post-filter with REGEXP - Seek to (2, *) with k2 < "b"
Filter Tree
Dual Purpose Data Structure
- Simultaneously used for:
- Range extraction via interval arithmetic
- Post-filtering of emitted rows
How It Works
Filter: (k1=1 AND k2="a" AND REGEXP(...)) OR (k1=2 AND k2<"b")
Filter Tree:
OR (union)
/ \
AND (intersection) AND (intersection)
/ | \ / \
k1=1 k2="a" REGEXP k1=2 k2<"b"
Extract k1 ranges:
Left AND: [1,1] ∩ (-∞,+∞) ∩ (-∞,+∞) = [1,1]
Right AND: [2,2] ∩ (-∞,+∞) = [2,2]
OR: [1,1] ∪ [2,2] = [1,2]
Extract k2 ranges (given k1=1):
Left AND: ["a","a"] but REGEXP unknown → ["a","a"]
Right AND: Unsatisfiable (k1≠2) → empty
OR: ["a","a"]
Extract k2 ranges (given k1=2):
Left AND: Unsatisfiable (k1≠1) → empty
Right AND: (-∞,"b")
OR: (-∞,"b")
Memoization and Pruning
- Cache predicate values that haven’t changed
- Prune unsatisfiable branches (empty intervals)
- Skip tautologies (infinite intervals)
- Efficient post-filter evaluation
Properties
- Produces minimal key ranges when possible
- Range computation is an approximation
- Exact isolation may require solving polynomials
- Diminishing returns for complex expressions
- Distribution range extraction is heuristic
- May send subquery to irrelevant shard
- Empty result returned if no matching data
- Trade-offs:
- Seeks vs. scans (I/O cost vs. CPU cost)
- Lock granularity (concurrency vs. overhead)
Query Restarts
What Are Query Restarts?
- Automatic recovery from transient failures
- Query continues from where it left off
- No client retry loops needed
- Works even after partial results consumed
Hidden Transient Failures
- Network disconnects
- Machine reboots
- Process crashes
- Distributed wait (replica not caught up)
- Data movement (resharding during query)
Benefits
Simpler Programming Model
- No retry loops in application code
- No complex backoff logic
- Multi-module queries possible
- Just set realistic deadlines
Streaming Pagination
- Long-running queries instead of:
-- Traditional paging (can eliminate ORDER BY!) SELECT ... LIMIT @page_size OFFSET @skip SELECT ... WHERE col > @last_value LIMIT @page_size - Can pause consumption without holding server resources
- Query transparently resumes on next read
Improved Tail Latency
- Minimal wasted work on restart
- Important for parallel queries across many machines
- Automatic retry from best replica
Forward Progress for Long Queries
- Queries can run longer than mean-time-to-failure
- Guaranteed completion (within garbage collection window)
Rolling Upgrades
- Deploy new server versions without disrupting queries
- Recurrent upgrades with no downtime
- Critical for Google’s rapid development cycle
Restart Contract
Restart Tokens
- Opaque blob sent with each result batch
- Added to original request to prevent duplicates
- No repeatability guarantee
SELECT * FROM T LIMIT 100 After returning 30 rows → restart with token → Returns 70 different rows (but no duplicates)
Streaming Implementation
- No persistent buffering of results
- Capture distributed state of query plan
- Small size, cheap to produce
Technical Challenges
Dynamic Resharding
- Shard boundaries change during query
- Server may lose/gain ownership
- Example:
Server executes query on shard [20, 30) → Interrupted → Same server now hosts merged shard [10, 40) → Must not re-execute [20, 30) portion - Solution: Track progress in key space, not shard IDs
Non-Determinism Sources
- Parallel subquery execution (network/load dependent)
- Blocking operator memory allocation
- Floating point computation order
- Must ensure repeatable results across restarts
Cross-Version Compatibility
- Must support restart across server versions N and N+1
- Requirements:
- Restart token wire format versioned
- Query plan must remain same across versions
- Operator behavior must be compatible
- Even without restart token, operators must not change order
Version Compatibility Example
Query starts on version N
↓
Returns 1000 rows
↓
Binary rollout: server upgrades to N+1
↓
Query restarts with token from version N
↓
Version N+1 must:
- Parse token from version N
- Use same query plan as version N
- Respect operator ordering from version N
Engineering Cost
- Restart token versioning mechanism
- Processes to force explicit versioning
- Framework to catch incompatibilities
- But worth it for stability and operational flexibility
Common SQL Dialect (Standard SQL)
Motivation
- Shared across Spanner, F1, Dremel, BigQuery
- Lower barrier for users working across systems
- Consistent syntax, semantics, NULL handling, etc.
Design Choices
- Fill gaps left by SQL standard
- Google-specific extensions
- Protocol Buffer integration
- MESSAGE and ENUM as first-class types
- UTF8 STRING type (not CHAR/VARCHAR)
- Nested data support (ARRAY, STRUCT)
Shared Components
1. Compiler Front-End
- Parsing, name resolution, type checking
- Prevents subtle divergences (coercion rules, scoping)
- Consistent error messages
- Outputs: Resolved Abstract Syntax Tree (AST)
- Systems convert Resolved AST to their algebras
2. Scalar Function Library
- Shared implementation of functions
- Consistency in corner cases
- Uniform overflow checking and runtime errors
3. Testing Framework
Compliance Tests
- Developer-written queries with expected results
- Cover primary use cases and corner cases
- Systems enable tests as they implement features
Coverage Tests - Random Query Generation
- Graph of node generators
- One generator per Resolved AST node type
- Example: FilterScanNodeGenerator
```
FilterScanNodeGenerator
↓ edges to
- Boolean expression generators (for condition)
- Scan generators (for input) ```
- Special generators for important patterns (e.g., key joins)
- Configurable: query size, scalar functions, etc.
Reference Implementation
- Produces annotated results
- Imprecise values (e.g., AVG with floats)
- Ordered vs. unordered lists
- Skips verification when unique result impossible
- Example: LIMIT without ORDER BY
- ~10% of random queries skip verification in unit tests
Continuous Testing
- Millions of random queries per day
- Random schema and data generation
- Bug reports help identify gaps in coverage
Blockwise-Columnar Storage (Ressi)
Background: Storage Concepts
OLTP vs OLAP Workloads
- OLTP (Online Transaction Processing)
- Short transactions (ms to seconds)
- Read/write small number of rows
- Examples: Insert order, update account balance
- Access pattern: Random reads/writes by key
- Metrics: Transactions per second, latency
- OLAP (Online Analytical Processing)
- Long-running queries (seconds to hours)
- Scan large portions of tables
- Examples: Sum sales by region, compute trends
- Access pattern: Sequential scans, many columns
- Metrics: Query throughput, data scanned
Row-Major vs Column-Major Layout
- Row-Major (traditional databases)
Row 1: [id=1, name="Alice", age=30, salary=100k] Row 2: [id=2, name="Bob", age=25, salary=80k] Row 3: [id=3, name="Carol", age=35, salary=120k] Storage: [1,"Alice",30,100k, 2,"Bob",25,80k, 3,"Carol",35,120k]- Good for OLTP: Fetching entire row is one I/O
- Bad for OLAP: Reading one column scans all data
- Column-Major (analytical databases)
Col id: [1, 2, 3] Col name: ["Alice", "Bob", "Carol"] Col age: [30, 25, 35] Col salary: [100k, 80k, 120k] Storage: [1,2,3, "Alice","Bob","Carol", 30,25,35, 100k,80k,120k]- Good for OLAP: Reading one column is efficient
- Bad for OLTP: Reconstructing row requires multiple I/Os
- PAX Layout (Partition Attributes Across)
Block 1 (rows 1-2): id: [1, 2] name: ["Alice", "Bob"] age: [30, 25] salary: [100k, 80k] Block 2 (rows 3-4): id: [3, 4] name: ["Carol", "Dave"] age: [35, 40] salary: [120k, 90k]- Hybrid: Row-major across blocks, column-major within blocks
- Benefits both OLTP and OLAP
- Ressi uses this approach
LSM-Tree (Log-Structured Merge Tree)
- Write-optimized data structure
- Key idea: Turn random writes into sequential writes
Structure:
Writes → MemTable (in-memory, sorted)
↓ (when full)
Flush to disk
↓
Level 0 (multiple sorted files, may overlap)
↓ (compaction)
Level 1 (sorted files, non-overlapping within level)
↓ (compaction)
Level 2 (10x larger than L1)
↓
Level 3 (10x larger than L2)
...
Example:
MemTable: [5→v5, 10→v10, 15→v15]
L0 File1: [1→v1, 10→v10_old, 20→v20]
L0 File2: [5→v5_old, 12→v12]
L1 File1: [1→v1, 5→v5_old, 8→v8]
L1 File2: [15→v15_old, 20→v20_old]
Operations:
- Write: Insert into MemTable (fast, in-memory)
- Read: Check MemTable → L0 → L1 → L2 → …
- May need to check multiple files (slower than B-tree)
- Bloom filters help skip files without the key
- Compaction: Merge files from Lᵢ into Lᵢ₊₁
- Removes deleted/old versions
- Maintains sorted order
- Background process
Bloom Filters
- Space-efficient probabilistic data structure
- Answers: “Is key X in this file?”
- Properties:
- Never false negatives: If says “no”, definitely not in file
- May have false positives: If says “yes”, might not be in file
- Very small: ~10 bits per key for 1% false positive rate
How It Works:
Bloom filter = bit array + k hash functions
Insert key "foo":
h1("foo") = 3 → Set bit[3] = 1
h2("foo") = 7 → Set bit[7] = 1
h3("foo") = 15 → Set bit[15] = 1
Check if "bar" exists:
h1("bar") = 3 → bit[3] = 1 ✓
h2("bar") = 7 → bit[7] = 1 ✓
h3("bar") = 10 → bit[10] = 0 ✗
Result: Definitely NOT in file
Check if "baz" exists:
h1("baz") = 3 → bit[3] = 1 ✓
h2("baz") = 7 → bit[7] = 1 ✓
h3("baz") = 15 → bit[15] = 1 ✓
Result: MAYBE in file (could be false positive)
Use in LSM-Trees:
- Each SSTable has a bloom filter
- Read path: Check bloom filter before reading file
- Example:
Read(key=500): Check MemTable: Not found L0: Check bloom filter → "No" → Skip file L1: Check bloom filter → "Yes" → Read file → Found! Saved expensive disk I/O on L0 file
Write Amplification:
- Data written multiple times during compactions
- Example: 1KB written by user → 10KB actual disk writes
- Trade-off: Fast writes vs. read performance
SSTables (Sorted String Tables)
- On-disk file format for LSM-tree levels
- Properties:
- Sorted by key
- Immutable (never modified, only created/deleted)
- Self-describing (stores schema with data)
- Structure:
SSTable File: [Index Block] → Points to data blocks [Data Block 1]: Keys [a, f), sorted [Data Block 2]: Keys [f, m), sorted [Data Block 3]: Keys [m, z), sorted [Metadata]: Schema, stats, bloom filter - Used by Bigtable, original Spanner, RocksDB, LevelDB
Motivation for Change
- SSTables inherited from Bigtable
- Optimized for schemaless NoSQL, large strings
- Self-describing, highly redundant
- Inefficient column traversal
- Spanner’s workload:
- Schematized data, small values
- Frequent column-wise access
- Hybrid OLTP/OLAP
Ressi Data Layout
LSM-Tree Structure
- Same overall structure as SSTables
- Periodic compaction of layers
- But different on-disk format (PAX instead of row-major)
Block Organization
Layer:
Block 1: [Row1, Row2, Row3, ...] ← Row-major order
Block 2: [Row50, Row51, Row52, ...]
...
Within Block (Column-major, PAX layout):
Col1: [val1_1, val1_2, val1_3, ...]
Col2: [val2_1, val2_2, val2_3, ...]
Col3: [val3_1, val3_2, val3_3, ...]
Table Interleaving Support
- Child table rows in same/nearby blocks as parent rows
- Preserves co-location for efficient joins
Multi-Level Index
- Fast binary search for random key access
File Organization
Active vs Inactive Files
- Active file: most recent values only
- Inactive file: older versions
- Benefit: Recent data queries avoid loading old versions
Large Value Segregation
- Multi-page values in separate files
- Fast scans without I/O cost of large values
- Load large values only when needed
Fundamental Data Structure: Vector
- Ordinally indexed sequence of homogeneous values
- Each column = one or multiple vectors
- Operate directly on compressed vectors
- No decompression needed for many operations
Live Migration from SSTables
Requirements
- Preserve data integrity
- Minimal impact on live workloads
- Reversible rollout
Migration Strategy
- Change group specification (including storage format)
- Use Spanner’s data movement mechanism
- Copy and convert data from old group to new group
- Old group continues serving live traffic
- New group takes over when ready
- Gradual per-group conversion
Flexibility
- Convert to Ressi replicas
- Mixed format replicas (testing/verification)
- Rollback to SSTables if needed
Migration Flow:
Group A (SSTables) Group B (Ressi)
[Replica 1, 2, 3] → [Replica 4, 5, 6]
↓ ↓
Serving live traffic Copying + converting
↓ ↓
Still serving Ready to serve
↓ ↓
Handoff
↓ ↓
Decommissioned Now serving traffic
Lessons Learned
Scalability First, Then Performance
- Horizontal scalability enabled deployment at scale
- Single-machine performance improved later
- Aggressive focus on distribution over single-node optimization
SQL Adoption Trajectory
- NoSQL API initially (simple point lookups, range scans)
- SQL added significant value for complex queries
- Switching to common dialect accelerated adoption:
- 2× increase in year before common dialect
- 5× increase in year after common dialect
- 4× increase in distributed query complexity
Transaction Guarantees
- External consistency (global ordering)
- Constraint: All updates committed at transaction end
- Limitation: Can’t read uncommitted results within transaction
- Low internal demand, but important for compatibility
TrueTime Epsilon Improvements
- Continued reduction in clock drift
- Simplified timestamp picking for queries
- Reads at now+epsilon avoids complex analysis
- Increases replica selection freedom
Schema Design Complexity
- Many physical layout options:
- Geographic placement
- Replication configuration
- Protocol buffers vs. interleaved tables
- Vertical partitioning
- Internal schema reviews prevent production issues
Query Optimization Challenges
- Declarative SQL + varying user sophistication
- Bi-weekly deployment cycle
- Optimizer changes can cause order-of-magnitude swings
- Challenge: Innovate while preserving workload stability
One-Size-Fits-All Remains Goal
- Combine OLTP, OLAP, full-text search in single system
- Reduces operational burden vs. multiple systems
- Single transactional semantics, API, deployment
- Focus on broad spectrum performance and cost-effectiveness
Key Insights
Architectural Rethinking for Scale
- Static partitioning → Dynamic resharding
- Node-addressed RPC → Data-range-addressed RPC
- Single replication config → Multiple configs for different workloads
- In-memory metadata → Metadata as data
- Simple control plane → Complex monitoring/management system
SQL vs NoSQL Reconciliation
- Scalable, manageable, transactional key-value = foundation
- Can support both loosely coupled (F1) and tightly coupled (Spanner) SQL
- SQL provides value for complex access patterns
- Recommendation: Start with relational model early
- Once scalable, available storage core exists
- Speeds development, reduces future migration costs
Evolution Path
- Scalability
- Manageability
- ACID transactions
- Relational model
- Schema DDL with nested data indexing
- SQL
For other systems following similar path: Start relational early!