Notes · Learn AWS · CHAPTER 6

Databases

The chapter where Cosmos DB intuition will bite you. DynamoDB looks like Cosmos Core but charges differently, partitions differently, and indexes differently. Meanwhile Aurora quietly outperforms Azure SQL on most workloads, and ElastiCache is the same Redis you know - just configured differently. Bring a pricing calculator.

Azure groups its data services into a tidy menu: Azure SQL for relational, Cosmos DB for multi-model NoSQL, Azure Cache for Redis for caching, Synapse for warehousing. AWS has more services, each more focused, and the boundaries between them are sharper. RDS is the relational umbrella with six engines. Aurora is the cloud-native flagship beneath that umbrella. DynamoDB is the NoSQL workhorse - a single-purpose key-value store that scales horizontally without you babysitting it, as long as you pick the right partition key. ElastiCache covers Redis and Memcached. Redshift handles the warehouse. We'll walk all of them.

In this chapter
  1. The cheat table
  2. RDS engines & HA
  3. Aurora deep-dive
  4. DynamoDB fundamentals
  5. DynamoDB capacity modes
  6. DynamoDB GSI & LSI
  7. ElastiCache (Redis & Memcached)
  8. Try it: inspect the database services
  9. Quick check (quiz)
  10. Gotchas for Azure devs
  11. Project Compass: gfn-reports-aggregates table
  12. Recap & next

The cheat table: Azure data services to AWS

ConceptAzureAWS
Managed SQL ServerAzure SQL Database / Managed InstanceRDS for SQL Server or Aurora (no SQL Server flavor) - choose RDS
Managed PostgreSQLAzure Database for PostgreSQL - Flexible ServerAurora PostgreSQL (preferred) or RDS PostgreSQL
Managed MySQLAzure Database for MySQL - Flexible ServerAurora MySQL (preferred) or RDS MySQL
NoSQL document/KVCosmos DB - Core (SQL) APIDynamoDB
MongoDB-compatibleCosmos DB - MongoDB APIDocumentDB (MongoDB-compatible)
Cassandra-compatibleCosmos DB - Cassandra APIKeyspaces (for Apache Cassandra)
Managed RedisAzure Cache for RedisElastiCache for Redis (now: ElastiCache (Valkey) / Redis OSS)
Memcachedno first-party MemcachedElastiCache for Memcached
Data warehouseAzure Synapse / SQL DWRedshift
Graph DBCosmos DB - Gremlin APINeptune
Time-seriesAzure Data Explorer / Time Series InsightsTimestream
Connection poolingPgBouncer add-on (PG flex)RDS Proxy (separate service, charged per vCPU/hour)
Serverless RDBAzure SQL Serverless (auto-pause)Aurora Serverless v2 (no auto-pause by default; v1 had it)
The row to internalize: Cosmos DB Core API to DynamoDB. They look similar - partition keys, RU/RCU billing, eventual consistency knobs - but the mental model diverges fast. DynamoDB has stricter item-size limits (400 KB vs 2 MB), tighter partition throughput ceilings (3,000 RCU / 1,000 WCU per partition), and a much narrower query surface. If you came from Cosmos and treat DynamoDB as "Cosmos with different syntax", you will write hot-partition designs and get throttled.
What's in a name? - the database glossary
RDS
Relational Database Service. Launched October 2009 with MySQL only. Today: PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, and Aurora variants. The "managed" part means AWS owns patching, backups, and failover. You still own the schema and the bad queries.
DynamoDB
Named after the 2007 Amazon "Dynamo" paper by Werner Vogels and team - one of the most-cited systems papers of the decade. The paper described an internal eventually-consistent KV store used for the Amazon shopping cart. The same design also inspired Cassandra and Riak. The public service launched in January 2012 with the lessons learned baked in.
Aurora
The Roman goddess of dawn. AWS launched the service in November 2014 as "the next generation of relational databases" - a poetic name signaling a fresh start. Internally still MySQL/PostgreSQL on the wire; the magic is the distributed storage layer underneath.
ElastiCache
A portmanteau of "elastic" (the AWS family marker, as in EC2, EBS, ELB) and "cache". Launched August 2011 as a Memcached service. Redis support arrived 2013 and is now the default engine. The Valkey fork of Redis 7.2 became the recommended default in 2024 after Redis Inc. changed the OSS license.
DocumentDB
Confusingly named - it's not a generic document database, it's an AWS service specifically built to be MongoDB API-compatible. The original Cosmos DB was also called "DocumentDB" before Microsoft renamed it (2017). AWS's DocumentDB shipped two years later with the same goal but a different implementation.
Keyspaces
Cassandra terminology - a "keyspace" is Cassandra's equivalent of a SQL database (a namespace for tables). AWS named the whole service after the term. Keyspaces is Cassandra-API-compatible but, underneath, runs on the same storage substrate as DynamoDB.
RCU / WCU
Read/Write Capacity Unit. The DynamoDB billing primitive in provisioned mode. 1 RCU = 1 strongly-consistent read per second of an item up to 4 KB (or 2 eventually-consistent reads). 1 WCU = 1 write per second of an item up to 1 KB. Items larger than that consume multiple units per call.
Redshift
A play on "red shift" (Doppler effect in astronomy, where distant galaxies look redder) and a swipe at the dominant warehouse vendor at the time of launch in 2012: Oracle (whose corporate color is, of course, red). The name implied "moving away from Oracle".

RDS engines & high availability

ELI5: RDS
Pick a relational database brand (Postgres, MySQL, Oracle, SQL Server, MariaDB, or Aurora) from a menu. AWS installs it on a managed VM for you, takes the backups, patches the OS, and offers a "twin in another zone" toggle for failover. You connect with a normal JDBC string. You still write the SQL and pay for the bad queries.

RDS is the umbrella service for managed relational databases. You pick an engine, a size, a storage type, and AWS handles the rest of the housekeeping.

EngineVersionsNotable
PostgreSQL13 - 17Most popular open-source choice. Supports many extensions (PostGIS, pg_cron, pgvector).
MySQL5.7, 8.0, 8.4Mature; Aurora MySQL is faster but more expensive.
MariaDB10.5 - 11.4Niche; pick MySQL or Aurora unless you specifically want MariaDB features.
Oracle19c, 21cBYOL or License Included. The expensive way out of on-prem Oracle.
SQL Server2017 - 2022The natural home for "lift Azure SQL workloads to AWS". License Included pricing.
AuroraMySQL-, PG-compatibleCloud-native flagship. Own section below.

Multi-AZ vs read replicas - two different things

Azure SQL hides these behind one toggle. RDS exposes them as two distinct features, and you'll pick one or both depending on what you actually want.

RDS Multi-AZ (HA) vs Read Replica (scale) Multi-AZ deployment (HA only) PRIMARY (AZ-a) writes + reads app endpoint STANDBY (AZ-b) no client traffic sync replication on failure: standby is promoted, DNS swap (~60-120 s) Standby cannot serve reads. It exists for failover only. Read Replicas (scale only) PRIMARY writes only REPLICA 1 read endpoint REPLICA 2 read endpoint REPLICA 3 cross-region Async replication. Reads from any replica. Replicas can be in different regions. Not automatic failover (must promote manually). Use both: Multi-AZ for HA + Read Replicas for read scale.
Multi-AZ is sync replication for failover; read replicas are async replication for read fan-out. They solve different problems and are billed separately.

Create an RDS Postgres instance

Azure CLI - Postgres Flexible Server
az postgres flexible-server create \
  --resource-group rg-data \
  --name compass-pg \
  --location eastus \
  --tier GeneralPurpose \
  --sku-name Standard_D2ds_v5 \
  --storage-size 64 \
  --version 16 \
  --high-availability ZoneRedundant \
  --admin-user pgadmin \
  --admin-password ""
AWS CLI - RDS Postgres
aws rds create-db-instance \
  --db-instance-identifier compass-pg \
  --db-instance-class db.m6g.large \
  --engine postgres \
  --engine-version 16.3 \
  --allocated-storage 64 \
  --storage-type gp3 \
  --master-username pgadmin \
  --master-user-password "" \
  --multi-az \
  --vpc-security-group-ids sg-0123abc \
  --db-subnet-group-name compass-db-subnets
Notice: AWS makes you bring the network. You must already have a db-subnet-group (created in chapter 3) and a security group ready. Azure asks for those at create time and provisions a default if you don't pass one. AWS will refuse to provision until the network is shaped.

Same database in Terraform

main.tf - aws_db_instance
resource "aws_db_subnet_group" "compass" {
  name       = "compass-db-subnets"
  subnet_ids = [for s in aws_subnet.private : s.id]
}

resource "aws_db_instance" "pg" {
  identifier              = "compass-pg"
  engine                  = "postgres"
  engine_version          = "16.3"
  instance_class          = "db.m6g.large"
  allocated_storage       = 64
  storage_type            = "gp3"
  username                = "pgadmin"
  password                = var.db_password    # move to Secrets Manager in chapter 8
  multi_az                = true
  db_subnet_group_name    = aws_db_subnet_group.compass.name
  vpc_security_group_ids  = [aws_security_group.db.id]
  backup_retention_period = 14
  deletion_protection     = true
  skip_final_snapshot     = false
  final_snapshot_identifier = "compass-pg-final"
  tags = { Project = "Compass" }
}
Two settings you'll forget and regret: deletion_protection = true stops a stray Terraform destroy from wiping the database. skip_final_snapshot = false ensures a snapshot is taken if it does get destroyed. Defaults for both are the unsafe value. Set them explicitly on every production DB.

Aurora - AWS's cloud-native RDBMS

ELI5: Aurora
Take Postgres or MySQL on the outside (same client driver, same SQL), but rip out the storage layer and replace it with a distributed log-replicated cluster that lives across 3 AZs. The compute box doesn't write to disk - it ships log records to the storage fleet, which materializes pages on demand. The result: faster, more replicas, faster failover, and weirder pricing. Roughly the same idea as Cosmos DB's storage-from-compute split, but with SQL on top instead of NoSQL.

AWS launched Aurora in 2014 with two claims: 5x MySQL performance and 3x PostgreSQL performance for typical OLTP workloads. The marketing numbers come with caveats, but the architecture is genuinely different and the wins on read-heavy workloads are real.

Aurora: storage layer is shared across compute instances Writer instance accepts writes db.r6g.large+ Reader 1 read endpoint Reader 2 also: failover target Reader 3 ... up to 15 Aurora shared storage fleet (across 3 AZs) copy 1 AZ-a log records copy 2 AZ-a copy 3 AZ-b copy 4 AZ-b copy 5 AZ-c copy 6 AZ-c writes log only 6 copies across 3 AZs, quorum writes (4/6), quorum reads (3/6). Auto-heals lost copies.
Compute instances are stateless. Killing a writer and promoting a reader takes seconds because no data needs to move - the storage is shared.
Fun fact Aurora's storage-separates-from-compute architecture predates the now-popular "compute-storage split" idea in databases like Snowflake, Neon, and PlanetScale by several years. The 2017 SIGMOD paper "Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases" describes the design and remains one of the most-quoted papers in the cloud-native database community. The key insight: the network is the bottleneck, so ship only redo log records, not full data pages.

Aurora Serverless v2 - elastic compute, no auto-pause

Aurora Serverless v2 lets you specify a min/max range of ACUs (Aurora Capacity Units) and the cluster scales compute up and down by half-ACU increments in response to load. Unlike v1 (which auto-paused to zero), v2 by default does not pause - the floor is whatever min you set, and you pay for that floor 24x7.

CapabilityAurora Serverless v1Aurora Serverless v2
Scale unitACU, doubles each step0.5 ACU, granular
Scale time30 s - 5 minseconds
Auto-pause to zeroyesno by default (added in 2024 for some engines)
Cold start cost5-30 s to wakenone (instance is always warm at min)
Statusdeprecated for most enginesrecommended
Azure analog: Azure SQL Database Serverless. Azure's variant does auto-pause to zero and charges you for storage only while paused, which is genuinely cheaper for sporadic workloads. Aurora Serverless v2's "always at min" model is more predictable but less cheap when traffic is bursty.

When NOT to pick Aurora

Stick with RDS PG/MySQL when:

Workload is small and steady (Aurora has a $0.10/hour floor for the writer; RDS Postgres on t4g.micro costs less than half).

You need a specific engine version that isn't in Aurora's compat matrix.

Stick with RDS for SQL Server/Oracle:

Aurora has no SQL Server or Oracle flavor. If you're lifting an Azure SQL workload, you're picking RDS for SQL Server.

Aurora makes sense when:

Read-heavy workloads (Aurora's 15-replica ceiling and shared storage shine).

You want fast failover (~30 s, no DNS swap to a separate physical replica).

You want point-in-time restore granular to the second across years of history.

DynamoDB fundamentals - partition keys & items

ELI5: DynamoDB
A giant filing cabinet where every drawer is picked by a hash. You hand DynamoDB an item (a dictionary of attributes); one of those attributes you nominated as the "partition key". DynamoDB hashes that key to decide which drawer the item lives in. Items in the same drawer can also have a "sort key" so they're stored in order. To find anything, you must either know the partition key (cheap) or scan every drawer (expensive). Pick the partition key like your wallet depends on it.

DynamoDB stores items (records with up to ~400 KB of attributes) in tables. Every table requires a primary key made of:

Partition key (PK, required)

Also called "hash key". Any item with the same PK lives in the same physical partition. Hashed under the hood; you don't see the hash.

If your table has only a PK, the PK must be unique across the whole table.

Sort key (SK, optional)

Also called "range key". Items with the same PK are sorted by SK within the partition. The unique key is then (PK, SK) together.

Enables "give me all items for user X between dates Y and Z" with a single query.

Item write: PK is hashed to pick a physical partition Item user_id : "u-7421" order_ts : "2026-05-22T..." total : 29.95 status : "PENDING" PK = user_id, SK = order_ts hash(user_id) md5-like, internal = 0x4f3a2b... partition P0 hash range 0x00-0x3f partition P1 hash range 0x40-0x7f partition P2 hash range 0x80-0xbf inside partition P1, sorted by SK PK=u-7421 SK=2026-05-22T08:00... PK=u-7421 SK=2026-05-22T08:14... PK=u-7421 SK=2026-05-22T11:02... PK=u-7421 SK=2026-05-23T09:30... PK=u-9132 SK=2026-05-21T... PK=u-9132 SK=2026-05-21T... all u-7421 items together, range query on date is one disk seek
All items with PK=u-7421 land on the same partition, sorted by sort key. Range queries within one user are fast. Cross-user queries are not - they require a Scan or a secondary index.

Create a table

Azure CLI - Cosmos DB Core SQL
az cosmosdb create \
  --name compass-cosmos \
  --resource-group rg-data \
  --locations regionName=eastus

az cosmosdb sql container create \
  --account-name compass-cosmos \
  --database-name reports \
  --name aggregates \
  --partition-key-path "/report_id" \
  --throughput 400
AWS CLI - DynamoDB
aws dynamodb create-table \
  --table-name compass-reports-aggregates \
  --attribute-definitions \
      AttributeName=report_id,AttributeType=S \
      AttributeName=period_start,AttributeType=S \
  --key-schema \
      AttributeName=report_id,KeyType=HASH \
      AttributeName=period_start,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --tags Key=Project,Value=Compass
Bug hunt: a partition key that will throttle by next quarter

A team is building a global SaaS that tracks user activity. Day-one design uses country_code as the DynamoDB partition key, with event_ts as the sort key. Traffic is roughly 80% US, 12% UK, 8% rest of world. Six weeks in they start hitting ProvisionedThroughputExceededException. They've already raised RCU/WCU. Why does it still throttle?

main.tf (the bug)
resource "aws_dynamodb_table" "events" {
  name         = "global-events"
  billing_mode = "PROVISIONED"
  read_capacity  = 5000
  write_capacity = 5000
  hash_key  = "country_code"   # PK
  range_key = "event_ts"       # SK
  attribute { name = "country_code" ; type = "S" }
  attribute { name = "event_ts"     ; type = "S" }
}
Click to reveal the bug
Hot partition. The "US" partition gets 80% of every write the table receives.

DynamoDB hashes the PK to assign physical partitions. With only ~5 distinct PK values (a handful of country codes), every "US" event lands on one partition. Each physical partition has a hard ceiling of 3,000 RCU / 1,000 WCU, no matter how much capacity you provision on the table - so once US traffic exceeds 1,000 writes per second, you throttle, even with 50,000 WCU provisioned table-wide.

Fix: use a high-cardinality PK or "compose" one. Two common patterns:

PK = country_code + "#" + user_id - cardinality jumps to "millions of users", traffic spreads evenly across partitions.

PK = country_code + "#" + (event_ts hour) - if access pattern is "events by country in the last hour", this keeps queries efficient while spreading load.

Never use a PK with low cardinality (status, country, category, environment). Hot-partition exceptions look like throughput problems but are shape problems.

Real-world incident Payments platform: status as partition key, 5-hour outage $500K lost revenue

A mid-sized fintech built a payment-processing service backed by DynamoDB. Their main table held one item per payment, with attributes payment_id, merchant_id, status (PENDING / SUCCEEDED / FAILED), amount, and timestamps. The engineer who designed the schema picked status as the partition key because their dominant query was "give me all pending payments to process".

It worked beautifully for six months at low volume. Then a marketing push doubled traffic over a weekend. By Monday morning, the system started returning timeouts on every payment creation - because every new payment was created with status = "PENDING", which meant every write hit the same physical partition. With ~3,000 writes per second sustained, the PENDING partition started throttling.

The retries piled up. The worker fleet, designed to scale on backlog depth, scaled out and made things worse - more workers all writing to the same overloaded partition. Customer payments started failing. The outage lasted 5 hours while engineers wrote a migration to re-shard the table using merchant_id as the PK.

Lessons: (1) Never use a low-cardinality attribute as a partition key. "Status", "type", "category", "environment", "country" are all red flags. (2) DynamoDB on-demand mode would have absorbed the spike more gracefully, but the underlying physical-partition ceiling of 1,000 WCU still applies. (3) The query "give me all items with status = PENDING" is best served by a Global Secondary Index with status as the PK, scanned offline, never by making status the primary PK.

DynamoDB capacity modes - on-demand vs provisioned

The single biggest cost lever in DynamoDB is the billing mode. Pick wrong and you'll over- or under-pay by an order of magnitude.

On-demand (PAY_PER_REQUEST)

Pay per request. ~$1.25 per million writes, ~$0.25 per million reads (eventually consistent). No capacity planning.

Good for: unpredictable traffic, dev/test, low-volume production, "I have no idea what my workload looks like yet".

Bad for: steady high-throughput workloads (it costs 7x provisioned at scale).

Provisioned + auto-scaling

Specify RCU/WCU floors and ceilings. Auto-scaling tracks actual usage and adjusts within bounds.

Good for: steady or predictable workloads where you can measure baseline RCU/WCU.

Bad for: spiky workloads with bursts more than 2x baseline - auto-scaling lags behind traffic.

Cost trap: on-demand looked cheap, then the workload stabilized ~$14K / month difference

A team picks on-demand mode for a new DynamoDB table because "we don't know the workload yet, we'll switch later". Six months on, the workload has stabilized at 5,000 requests per second (mix of reads and writes, 4 KB items) for ~720 hours per month. They're paying about $16,000/month for DynamoDB. Their finance team flags it. The engineer says "but on-demand is the recommended default". What's the actual math, and what's the fix?

Click to reveal the math
On-demand cost (sustained):

5,000 req/s × 3,600 s/hr × 720 hr/mo = 12.96 billion requests/month

At a blended rate of ~$1.25 per million writes (assuming write-heavy mix; reads are cheaper at $0.25/M but let's be conservative):

12.96B × $1.25 / 1M = ~$16,200/month

Provisioned cost (with auto-scaling):

At steady state, 5K writes/sec needs 5,000 WCU (1 WCU = 1 write/sec for 1 KB items; assume a bit higher with 4 KB so ~6K WCU peak, scaling down at night).

5,000 WCU × $0.00065/WCU-hr × 720 hr = $2,340/month

Plus reads at maybe ~$200/month. Total: ~$2,200-2,500/month.

The markup: 7.3x. $14,000/month walking out the door because nobody flipped the switch.

Fix:

1. Enable Contributor Insights or CloudWatch ConsumedReadCapacityUnits/ConsumedWriteCapacityUnits metrics. Track the median and p95 over 4 weeks.

2. Once the workload looks predictable (p95 within 2x of p50), switch to provisioned with auto-scaling. Target utilization: 70%. Min: p50 estimate. Max: 2x p95.

3. For seasonal traffic (e.g., gaming workloads with 8 PM peaks), schedule auto-scaling adjustments via Application Auto Scaling. Or use Reserved Capacity for the 24x7 floor (38% discount with 1-year commit).

The rule of thumb: on-demand is the right default for new tables. It becomes the wrong default once the workload stabilizes - and almost nobody goes back to flip the switch unless finance reads the bill.

The crossover - when to switch

Cost vs sustained load: on-demand (orange) vs provisioned (green) sustained throughput (writes/sec) $ / month 0 500 1500 3000 5000 $0 $5K $10K $16K on-demand provisioned + auto-scaling crossover ~$2-3K/mo ~1000 sustained req/s
Crossover is roughly $2-3K/month, or about 1,000 sustained req/s. Below that, on-demand wins on operational simplicity. Above that, provisioned wins on cost.

Secondary indexes - GSI & LSI

ELI5: indexes
Your table is sorted one way (by PK then SK). If you want to query a different way - "give me all PENDING items across all users", "find users by email" - you need a second copy of the table sorted differently. That second copy is a secondary index. Local indexes share storage with the main table; global indexes are a fully separate replicated copy with their own RCU/WCU.

DynamoDB has two flavors of secondary index. Pick wrong and you'll either pay double or fail to add the index at all (LSI must be defined at table-create time).

AspectLSI (Local Secondary Index)GSI (Global Secondary Index)
Partition keyMust be the same as the base tableAny attribute
Sort keyDifferent from base tableDifferent from base table
StorageSame partition as the itemSeparate partitioning, replicated copy
ConsistencyStrong reads supportedEventually consistent only
CreatedAt table creation only (cannot add later)Any time, online
CapacityShares the table's RCU/WCUOwn RCU/WCU (or on-demand)
QuantityMax 5 per tableMax 20 per table (default; can request up to 50)
CostStorage only (no extra capacity)Storage + capacity (essentially a 2nd table)

Projection types - what columns to keep in the index

KEYS_ONLY

Only the primary key (PK + SK) and the index keys. Smallest, cheapest. Forces a base-table lookup for every other attribute.

Use when: queries return tiny result sets you can afford to look up individually.

INCLUDE

Keys plus a specific list of attributes you nominated. Middle ground.

Use when: you know exactly which attributes the index's queries need. Most common.

ALL

Every attribute. Index is a full copy. Largest, most expensive in storage and WCU.

Use when: queries return many items and you can't afford the base-table fan-out cost.

Important: GSI reads are always eventually consistent. Even if your code asks for ConsistentRead=true, DynamoDB returns an error for GSI queries. If you absolutely need strong consistency, use an LSI - but remember LSI partition key must match the base table.

Define a GSI in Terraform

main.tf - DynamoDB with GSI on status (offline scanner)
resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "merchant_id"     # base PK - high cardinality
  range_key    = "order_id"        # base SK

  attribute { name = "merchant_id" ; type = "S" }
  attribute { name = "order_id"    ; type = "S" }
  attribute { name = "status"      ; type = "S" }
  attribute { name = "updated_ts"  ; type = "S" }

  global_secondary_index {
    name            = "status-updated_ts-index"
    hash_key        = "status"        # GSI PK - ok here because
    range_key       = "updated_ts"    # this index is scanned offline only
    projection_type = "KEYS_ONLY"     # worker just needs IDs, looks up rest
  }

  point_in_time_recovery { enabled = true }
  server_side_encryption { enabled = true }
}

The trick here is making the base table partition cleanly by merchant_id (so the live write path is hot-partition-free), and exposing a status-based GSI just for the offline worker that periodically scans for new PENDING items. The GSI's PK can be low-cardinality because nothing is hammering it in real-time.

ElastiCache - Redis and Memcached

ELI5: ElastiCache
AWS-managed Redis (or Memcached) on a VM. You point your app at an endpoint. AWS handles failover, backups (Redis only), patching, and clustering. It's the same Redis API your code already uses; configuration knobs differ from Azure but the protocol on the wire is identical.

ElastiCache has two engines, and the choice is mostly settled by the data structures you need.

ElastiCache (Valkey) / Redis OSS

Default in 2025+. Valkey is the OSS fork after Redis Inc. relicensed Redis to SSPL in 2024. AWS contributed to and maintains Valkey now.

Supports lists, hashes, sorted sets, streams, geo, pub-sub. Persistence via AOF/snapshots. Multi-AZ with auto-failover. Cluster mode for sharding.

ElastiCache for Memcached

Pure KV cache. No persistence. No replication. Larger key counts per node (no overhead).

Use only if you need: multi-threaded engine (Redis is single-threaded per node), or simple cache-aside on raw string values.

Cluster mode vs replica-only

ModeWhat it isUse when
Cluster mode disabledSingle shard (primary) + up to 5 read replicas. All keys on one node.Working set fits in one node's RAM. Simpler client config.
Cluster mode enabledMultiple shards, hash-slot-partitioned. Up to 500 shards.Working set exceeds one node. Need horizontal scale.
Fun fact AWS contributed substantial performance work back to the open-source Redis project for years before the 2024 license change - notably the multi-threaded I/O subsystem in Redis 6. ElastiCache for Redis (and now Valkey) typically outperforms Azure Cache for Redis on identical hardware because of AWS-specific kernel and network tuning. Independent benchmarks from 2023 showed roughly 25-40% higher p99 throughput on equivalent SKUs.
Azure CLI - Azure Cache for Redis
az redis create \
  --name compass-cache \
  --resource-group rg-data \
  --location eastus \
  --sku Standard \
  --vm-size C1 \
  --enable-non-ssl-port false
AWS CLI - ElastiCache (Valkey)
aws elasticache create-cache-cluster \
  --cache-cluster-id compass-cache \
  --engine valkey \
  --cache-node-type cache.t4g.small \
  --num-cache-nodes 1 \
  --engine-version 7.2 \
  --cache-subnet-group-name compass-cache-subnets \
  --security-group-ids sg-cache0123
Network gotcha: ElastiCache is only reachable from within a VPC. There is no public endpoint, ever. Your code has to run inside the VPC (Lambda with VPC config, EC2, ECS task, EKS pod) to talk to the cache. Azure Cache for Redis has a public endpoint by default - this trips up Azure devs porting "just cache it" code paths.

Try it: inspect AWS database services

Lab: list, describe, and (optionally) create + delete $0

Goal: get hands on with the DynamoDB and RDS CLI surface. The first three commands are pure reads ($0). The optional create/delete at the end uses on-demand DynamoDB and costs effectively zero if you don't write any data.

Step 1. List DynamoDB tables in your account.

terminal
aws dynamodb list-tables --profile compass
# empty {"TableNames": []} on a fresh account is fine

Step 2. Check your DynamoDB account-level limits (RCU/WCU ceilings).

terminal
aws dynamodb describe-limits --profile compass

# Returns per-account and per-table provisioned-capacity caps.
# Defaults: 80,000 RCU and 80,000 WCU per region, raisable via support.

Step 3. List available RDS engine versions (great for spotting deprecated ones).

terminal
aws rds describe-db-engine-versions \
  --engine postgres \
  --query 'DBEngineVersions[?contains(EngineVersion, `16`)].[EngineVersion,Status]' \
  --output table \
  --profile compass

# Look for "available" status; "deprecated" engines still work but won't
# get new features and have an end-of-support date.

Step 4 (optional). Create a tiny on-demand DynamoDB table, then delete it. Costs near zero if you write no items.

terminal
aws dynamodb create-table \
  --table-name learn-aws-lab \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --profile compass

aws dynamodb wait table-exists --table-name learn-aws-lab --profile compass
aws dynamodb describe-table --table-name learn-aws-lab --profile compass \
  --query 'Table.[TableName,TableStatus,BillingModeSummary.BillingMode]' \
  --output table

aws dynamodb delete-table --table-name learn-aws-lab --profile compass

What you learned: DynamoDB tables can be created and torn down in seconds. There is no provisioning lead time like Cosmos (Cosmos typically takes 1-3 minutes for a new container). Use this freedom in dev: spin up a sandbox table per developer, throw it away when done.

Quick check

Test yourself - 5 questions

Database design choices stick around for years. Spend a minute on each one before peeking.

1. What is the most common root cause of a DynamoDB hot partition?

  • Insufficient RCU/WCU provisioned at the table level.
  • A partition key with low cardinality (few distinct values).
  • Using sort key instead of partition key.
  • Not enabling DAX (DynamoDB Accelerator).
Show answer
Answer: b. Every DynamoDB physical partition has a hard ceiling of 3,000 RCU / 1,000 WCU regardless of how much table-level capacity you provision. If your PK has few distinct values (status, country, type, environment), traffic concentrates on one partition and that physical ceiling becomes your real ceiling. The fix is always cardinality - either pick a higher-cardinality PK or compose one (e.g., country#user_id).

2. What's the difference between an RDS Multi-AZ deployment and an RDS read replica?

  • They're the same thing with different names.
  • Multi-AZ is for high availability (sync replication, standby cannot serve reads, automatic failover). Read replicas are for read scaling (async, can serve reads, no automatic failover).
  • Multi-AZ is async, read replicas are sync.
  • Multi-AZ only works within one AZ; read replicas span AZs.
Show answer
Answer: b. Multi-AZ uses synchronous replication and the standby cannot serve traffic - it exists only to be promoted on failover (~60-120 s, automatic). Read replicas use async replication, can serve reads behind a separate endpoint, and don't automatically promote on failure. They solve different problems and are often used together: Multi-AZ for HA, read replicas for read scale.

3. Does Aurora Serverless v2 have a "cold start" delay when scaling from zero?

  • Yes - typically 30-60 seconds when scaling from zero.
  • No - v2 doesn't pause to zero by default. The instance is always warm at the configured minimum ACU.
  • Yes, but only on PostgreSQL; MySQL is always warm.
  • Only when using cross-region replicas.
Show answer
Answer: b. Aurora Serverless v2 (unlike v1) does not auto-pause to zero by default. You set a minimum ACU and pay for that floor 24x7 - the cluster is always warm and scales up in half-ACU increments within seconds. The trade-off vs Azure SQL Serverless (which does pause to zero and is cheaper for sporadic workloads) is predictability vs cost. AWS did add an opt-in auto-pause feature in 2024 for some engines, but the default behavior is "always-on at min".

4. At what sustained request rate does DynamoDB provisioned typically beat on-demand on cost?

  • ~100 req/s
  • ~1,000 req/s sustained (roughly $2-3K/month spend)
  • ~10,000 req/s
  • Never - on-demand is always cheaper for production workloads.
Show answer
Answer: b. The crossover is roughly $2-3K/month, or about 1,000 sustained req/s. Below that, on-demand wins on operational simplicity. Above that, provisioned + auto-scaling wins on cost - by as much as 7x at 5,000 req/s sustained. Always set a billing alarm and revisit the choice every quarter as workloads stabilize.

5. Can you do a strongly consistent read against a DynamoDB Global Secondary Index (GSI)?

  • Yes, if you set ConsistentRead=true on the query.
  • Yes, if the GSI has projection type ALL.
  • No - GSI reads are always eventually consistent. Asking for strong consistency returns an error.
  • Only on Single-AZ tables.
Show answer
Answer: c. GSI maintains a separately partitioned, async-replicated copy of the indexed attributes. Strong consistency would require synchronous cross-partition coordination that DynamoDB doesn't offer for GSIs. If you absolutely need strong reads on a non-primary key, your options are: (1) use a Local Secondary Index (LSI), which shares the base table's partition and supports strong reads but requires the same PK as the base table, or (2) read from the base table directly. Don't paper over this with retries; eventual consistency on a GSI is typically just milliseconds, but it's not zero.

Gotchas for Azure devs

1. DynamoDB on-demand is up to 7x more expensive than provisioned at sustained scale. The default is on-demand because it's operationally simple, but it's a "discovery mode" pricing tier. Once a workload looks predictable (p95 within 2x of p50 for 4+ weeks), switch to provisioned with auto-scaling. A single conscientious finance review can save $10K-$50K/year per table.
2. RDS automated backups max out at 35 days retention. Azure SQL gives you up to 10 years of long-term retention out of the box. RDS automated backups cap at 35 days; for anything longer you have to manually create snapshots (or schedule a Lambda to do it) and tag them for retention. AWS Backup is the official long-term retention service - separate billing, separate setup.
3. Aurora "I/O storms" during scale-out events. Adding a read replica or scaling the writer can trigger a brief spike in IOPS as buffer caches warm. On Aurora Serverless v2 this looks like a latency blip every time the cluster scales an ACU. Mitigations: set the min ACU higher (so scale events are rarer), or use the I/O-Optimized cluster configuration (flat I/O pricing, ~30% more expensive for compute but no per-IO charge).
4. DynamoDB attribute names count toward the 400 KB item-size limit. Long descriptive names like customer_billing_address_postal_code repeated across millions of items burn storage and capacity. Many teams adopt 2-3 character attribute names in production (bz, cn) for hot tables. Map back to human names in the application layer. Ugly but real - the difference between "fits in 4 KB" (1 RCU) and "needs 8 KB" (2 RCU) is one long attribute name.
5. RDS connections are expensive - use RDS Proxy. Each Postgres connection eats 5-15 MB of memory on the server side. With Lambda, every concurrent invocation can open its own connection - a steady 200 concurrent invocations easily exhausts a db.t3.medium. Solution: RDS Proxy (~$0.015/vCPU-hr per db instance vCPU; small but worth it) pools connections at the proxy layer. Lambdas connect to the proxy, which multiplexes onto a small pool of real DB connections. Set the connection limit on the proxy, not on the Postgres role.
6. The "free tier" hides RDS storage charges. RDS free tier covers compute for 12 months but storage charges (~$0.115/GB-mo for gp3) apply from day one. A 64 GB dev instance costs about $7.40/month in storage alone - small but easy to forget after the compute tier expires.

Project Compass: gfn-reports-aggregates table

Picking up from chapter 5, where we added an S3 bucket for raw telemetry and extended the role's policy. In this chapter's slice we create the DynamoDB table that gfn-reports will write aggregated session metrics into, then attach read/write permissions on the table to gfn-reports-role.

Project Compass · Step 6 of 12 Create gfn-reports-aggregates table

Why now: the worker (currently a Lambda) will read raw telemetry from S3 and write aggregated rows to DynamoDB. We need the table before chapter 7 introduces the SQS queue that triggers the worker.

Schema design

Partition key: report_id (String) - high cardinality (one per merchant or game session, depending on the aggregation grain). Sort key: period_start (String, ISO-8601 timestamp) - so the same report's time-bucketed rows stay sorted within one partition.

Capacity mode: on-demand for now. We have no idea what the workload looks like yet. Chapter 12 will revisit this with real CloudWatch data and consider switching to provisioned.

terminal · create the table
aws dynamodb create-table \
  --table-name gfn-reports-aggregates \
  --attribute-definitions \
      AttributeName=report_id,AttributeType=S \
      AttributeName=period_start,AttributeType=S \
  --key-schema \
      AttributeName=report_id,KeyType=HASH \
      AttributeName=period_start,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --sse-specification Enabled=true \
  --tags Key=Project,Value=Compass Key=ManagedBy,Value=learn-aws \
  --profile compass

aws dynamodb wait table-exists \
  --table-name gfn-reports-aggregates \
  --profile compass

aws dynamodb describe-table \
  --table-name gfn-reports-aggregates \
  --query 'Table.[TableName,TableStatus,KeySchema,BillingModeSummary.BillingMode]' \
  --output table \
  --profile compass
Grant the role read+write on this table only
/tmp/compass-dynamo-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AggregatesTableRW",
    "Effect": "Allow",
    "Action": [
      "dynamodb:GetItem",
      "dynamodb:BatchGetItem",
      "dynamodb:Query",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
      "dynamodb:BatchWriteItem"
    ],
    "Resource": [
      "arn:aws:dynamodb:us-east-1:<ACCOUNT>:table/gfn-reports-aggregates",
      "arn:aws:dynamodb:us-east-1:<ACCOUNT>:table/gfn-reports-aggregates/index/*"
    ]
  }]
}
terminal · attach the inline policy to the role
aws iam put-role-policy \
  --role-name gfn-reports-role \
  --policy-name AggregatesTableRW \
  --policy-document file:///tmp/compass-dynamo-policy.json \
  --profile compass

# Verify - should now list S3 (from ch5), Lambda basic exec (from ch2), and the new policy
aws iam list-role-policies \
  --role-name gfn-reports-role \
  --profile compass

aws iam list-attached-role-policies \
  --role-name gfn-reports-role \
  --profile compass

We deliberately scoped the policy to just this one table (and its future indexes via index/*). No dynamodb:*, no Resource: "*". Future chapters add KMS permissions (ch 8) once we move from SSE-S3 to a customer-managed key.

Progress
ch1 · profile ch2 · IAM role ch3 · VPC ch4 · Lambda ch5 · S3 ch6 · DynamoDB ch7 · SQS ch8 · KMS ch9 · alarms ch10 · EKS ch11 · API GW ch12 · Terraform
Don't delete this table. The worker we deploy in chapter 7 writes to it. If you do need a clean reset later: aws dynamodb delete-table --table-name gfn-reports-aggregates --profile compass. Because we picked on-demand mode and added no items, deletion is instant and free.

Recap & next

What stuck?

The mental model in one sentence

Azure groups data services by data model (SQL, Cosmos, Cache, Synapse). AWS groups them by workload shape (RDS for OLTP relational, DynamoDB for KV at scale, ElastiCache for cache, Redshift for warehouse). Pick by the access pattern, not by the data shape - especially for DynamoDB where the access pattern dictates the partition key dictates everything else.

Common pitfalls so far

TrapFix
"My DynamoDB writes throttle even though I provisioned 10,000 WCU"Low-cardinality PK creating a hot partition. Per-partition ceiling is 1,000 WCU regardless of table capacity. Re-shard the PK.
"My on-demand DynamoDB bill exploded as we grew"Workload stabilized but billing mode didn't. Switch to provisioned + auto-scaling once p95 is within 2x of p50.
"My Lambda exhausts Postgres connections"Add RDS Proxy. Connect Lambdas to the proxy endpoint instead of the DB endpoint.
"Aurora Serverless v2 is more expensive than I expected"You set a min ACU that's too high, or you're paying for I/O on a high-IO workload. Lower min, or switch to I/O-Optimized cluster pricing.
"I can't reach my ElastiCache cluster from my laptop"By design - ElastiCache is VPC-only. Use a bastion host, SSM Session Manager port-forward, or VPN.
NEXT CHAPTER
7. Messaging & Events
SQS, SNS, EventBridge, Kinesis - and why this is the chapter with the closest 1:1 Azure mapping of the whole series.