Introduction to Troubleshooting Spanner
Google Cloud Spanner is a globally distributed, strongly consistent relational database designed for high availability and horizontal scalability. While Spanner handles many complexities of distributed databases automatically, developers still encounter issues related to performance, schema design, transaction management, and connectivity. This tutorial covers the most common Spanner issues and provides practical solutions to diagnose and resolve them.
Why Troubleshooting Spanner Matters
Spanner's unique architecture—combining the scalability of NoSQL with the semantics of a relational database—means that traditional database troubleshooting techniques don't always apply. Understanding Spanner-specific failure modes helps you maintain low latency, avoid costly anti-patterns, and ensure your application remains reliable at scale. A single poorly designed query or schema choice can cause cascading performance problems across your entire instance.
Issue 1: High Latency on Queries
High latency is one of the most frequently reported Spanner issues. It often stems from full table scans, missing secondary indexes, or hot keys. Spanner's query optimizer is powerful, but it cannot compensate for fundamentally inefficient query patterns.
Diagnosing Latency with Query Plans
Use the Cloud Console's Query Insights or the gcloud spanner databases execute-sql command with the --query-mode=PLAN flag to inspect query execution plans. Look for Scan operators that show Full scan rather than Index scan.
# Execute a query and view its plan
gcloud spanner databases execute-sql my-database \
--instance=my-instance \
--sql="SELECT * FROM Orders WHERE customer_id = 123" \
--query-mode=PROFILE
Solution: Add Secondary Indexes
If your query filters on a non-key column, create a secondary index to avoid full table scans:
CREATE INDEX OrdersByCustomerId ON Orders(customer_id);
For queries that also need to retrieve non-indexed columns, consider a storing index to avoid a join back to the base table:
CREATE INDEX OrdersByCustomerIdWithStatus ON Orders(customer_id) STORING (status, total);
Solution: Use Query Hints When Needed
Sometimes the optimizer picks a suboptimal plan. You can force index usage with a query hint:
SELECT *
FROM Orders@{FORCE_INDEX=OrdersByCustomerId}
WHERE customer_id = 123;
Issue 2: Hotspotting on Primary Keys
Hotspotting occurs when a disproportionate amount of read or write traffic targets a single split. This is common when primary keys are monotonically increasing (like timestamps or auto-incrementing IDs), because Spanner distributes data by key ranges and new keys all land in the same split.
Detecting Hotspotting
Monitor the Cloud Spanner metrics dashboard for:
instance/cpu/utilization— uneven CPU usage across nodesinstance/cpu/utilization_by_priority— high priority CPU saturationinstance/transaction/lock_wait_time— increasing lock contention
Solution: Use Hash-Sharded Keys
Prepend a hash or shard key to distribute writes evenly across splits:
-- Create a table with a hash-prefixed primary key
CREATE TABLE Orders (
shard_id INT64 NOT NULL,
order_id STRING(36) NOT NULL,
customer_id INT64 NOT NULL,
created_at TIMESTAMP NOT NULL,
total NUMERIC NOT NULL,
) PRIMARY KEY (shard_id, order_id);
When inserting, compute the shard by hashing a natural key:
import hashlib
def generate_shard_id(order_id: str, num_shards: int = 10) -> int:
h = hashlib.sha256(order_id.encode()).hexdigest()
return int(h, 16) % num_shards
order_id = "550e8400-e29b-41d4-a716-446655440000"
shard_id = generate_shard_id(order_id)
print(f"shard_id={shard_id}, order_id={order_id}")
Solution: Use Bit-Reversed Sequences
If you need sequential-looking IDs but want to avoid hotspotting, use a bit-reversed sequence. This spreads consecutive values across the key space:
def bit_reverse(x: int, bits: int = 64) -> int:
result = 0
for i in range(bits):
if x & (1 << i):
result |= 1 << (bits - 1 - i)
return result
next_seq = 1001
reversed_id = bit_reverse(next_seq)
print(f"Original: {next_seq}, Reversed: {reversed_id}")
Issue 3: Transaction Aborts and Deadlocks
Spanner uses two-phase commit and optimistic concurrency control. Long-running transactions or transactions that touch many rows are more likely to be aborted due to write-write conflicts or lock contention. When a transaction aborts, Spanner returns an ABORTED error, which the client library typically retries automatically—but only if you structure your code correctly.
Correct Transaction Retry Pattern
Always use the client library's transaction runner, which handles retries internally. Never catch AbortedError and retry manually with a custom loop unless you understand the backoff semantics.
from google.cloud import spanner
client = spanner.Client()
instance = client.instance("my-instance")
database = instance.database("my-database")
def transfer_funds(from_account, to_account, amount):
def txn(transaction):
# Read both rows with a shared lock
rows = transaction.execute_sql(
"SELECT balance FROM Accounts WHERE account_id IN (@from, @to)",
params={"from": from_account, "to": to_account},
param_types={"from": spanner.param_types.INT64, "to": spanner.param_types.INT64},
).rows
balances = {row[0]: row[1] for row in rows}
if balances.get(from_account, 0) < amount:
raise ValueError("Insufficient funds")
transaction.execute_update(
"UPDATE Accounts SET balance = balance - @amount WHERE account_id = @from",
params={"amount": amount, "from": from_account},
param_types={
"amount": spanner.param_types.INT64,
"from": spanner.param_types.INT64,
},
)
transaction.execute_update(
"UPDATE Accounts SET balance = balance + @amount WHERE account_id = @to",
params={"amount": amount, "to": to_account},
param_types={
"amount": spanner.param_types.INT64,
"to": spanner.param_types.INT64,
},
)
# The library handles ABORTED retries automatically
database.run_in_transaction(txn)
transfer_funds(101, 202, 500)
Reducing Abort Rates
- Keep transactions short—avoid external API calls or user input inside a transaction.
- Read data only once per transaction; re-reads increase the chance of conflicts.
- Use
read-onlytransactions for queries that don't need writes; they never abort. - Batch writes to reduce the number of separate transactions.
- Consider commit timestamps (
pending_commit_timestamp) for ordered writes instead of locking.
Issue 4: Connection and Authentication Errors
Spanner client libraries use gRPC under the hood. Common connection errors include UNAVAILABLE, DEADLINE_EXCEEDED, and PERMISSION_DENIED. These often relate to network configuration, IAM roles, or quota limits.
Common Error Causes
UNAVAILABLE— transient network issue or instance is temporarily overloaded.DEADLINE_EXCEEDED— request timeout too low or query is too slow.PERMISSION_DENIED— service account lacksroles/spanner.databaseUser.RESOURCE_EXHAUSTED— exceeded API rate limits or instance CPU capacity.
Solution: Configure Retry and Timeout Settings
from google.cloud import spanner
from google.api_core import retry
# Custom retry policy for transient errors
custom_retry = retry.Retry(
initial=1.0,
maximum=60.0,
multiplier=2.0,
deadline=300.0,
predicate=retry.if_exception_type(
ConnectionError,
TimeoutError,
),
)
client = spanner.Client(
project="my-project",
client_options={"api_endpoint": "staging-wrenchworks.sandbox.googleapis.com:443"},
)
# Increase gRPC timeout for long-running queries
database = instance.database(
"my-database",
pool=spanner.database.BurstyPool(labels={"checkout-pool": "v1"}),
)
Solution: Verify IAM Permissions
# Grant database user role to a service account
gcloud spanner databases add-iam-policy-binding my-database \
--instance=my-instance \
--member="serviceAccount:app@my-project.iam.gserviceaccount.com" \
--role="roles/spanner.databaseUser"
# Verify the binding
gcloud spanner databases get-iam-policy my-database \
--instance=my-instance
Issue 5: Schema Migration Failures
Spanner supports online schema changes, but certain operations are long-running and can fail if they conflict with concurrent DDL statements or if the instance is under heavy load.
Solution: Batch DDL Statements and Monitor Progress
# Submit multiple DDL statements as a batch
gcloud spanner databases ddl update my-database \
--instance=my-instance \
--ddl='CREATE INDEX OrdersByStatus ON Orders(status);' \
--ddl='CREATE TABLE AuditLog (log_id STRING(36) NOT NULL, event STRING(MAX)) PRIMARY KEY(log_id);'
# Check progress of long-running operations
gcloud spanner operations list --instance=my-instance
Best Practices for Schema Changes
- Avoid running more than a few concurrent DDL statements on the same database.
- Backfill large tables in batches rather than using a single massive UPDATE.
- When adding a NOT NULL column, first add it as nullable, backfill, then add a NOT NULL constraint.
- Test schema changes on a replica or staging instance first.
Issue 6: Out of Memory and Client-Side Resource Leaks
Streaming large result sets without proper resource management can cause memory exhaustion on the client. Always consume or close result streams, and use connection pooling appropriately.
Solution: Stream Results with Pagination
def stream_large_table(database, batch_size=10000):
def fetch_batch(txn, offset):
results = txn.execute_sql(
"SELECT order_id, total FROM Orders ORDER BY order_id LIMIT @limit OFFSET @offset",
params={"limit": batch_size, "offset": offset},
param_types={
"limit": spanner.param_types.INT64,
"offset": spanner.param_types.INT64,
},
)
return list(results)
offset = 0
while True:
batch = database.run_in_transaction(fetch_batch, offset)
if not batch:
break
for row in batch:
yield row
offset += batch_size
for order in stream_large_table(database):
process_order(order)
Best Practices Summary
- Design keys for distribution: Avoid monotonically increasing primary keys; use hash sharding or bit-reversed sequences.
- Index strategically: Create secondary indexes for common filter columns; use storing indexes to avoid base table lookups.
- Profile queries regularly: Use Query Insights to identify slow queries and full scans before they impact production.
- Keep transactions short: Minimize the work inside read-write transactions to reduce abort rates.
- Monitor CPU utilization: Scale your instance before hitting sustained high CPU, which indicates you need more nodes.
- Use the latest client library: Newer versions include improved retry logic and connection pooling.
- Handle errors gracefully: Distinguish between transient errors (retry) and permanent errors (fail fast).
Conclusion
Troubleshooting Cloud Spanner effectively requires understanding its distributed architecture and the specific patterns that lead to performance problems. By addressing hotspotting through thoughtful key design, optimizing queries with proper indexing, structuring transactions to minimize aborts, and configuring robust retry logic, you can keep your Spanner-backed applications fast and reliable. The key is to combine proactive monitoring with a solid grasp of these common failure modes—so that when issues arise, you can diagnose and resolve them quickly without disrupting your users.