AWS Storage & Databases
S3, RDS, Aurora, ElastiCache, DynamoDB — the data layer every backend engineer uses.
S3 (Simple Storage Service)
S3 is object storage — flat namespace of buckets containing objects (key/value pairs). Not a filesystem; no directories, just key prefixes that look like paths.
Storage Classes
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef yellow fill:#f39c12,stroke:#d68910,color:#000
classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
HOT["Standard<br/>$0.023/GB"]:::blue
SMART["Intelligent-Tiering<br/>auto-tier"]:::teal
IA["Standard-IA<br/>$0.0125/GB"]:::orange
ZONE["One Zone-IA<br/>$0.01/GB"]:::yellow
GLACIER["Glacier Instant<br/>$0.004/GB"]:::dark
DEEP["Glacier Deep Archive<br/>$0.00099/GB"]:::dark
HOT --> SMART --> IA --> ZONE --> GLACIER --> DEEP
| Class | Price/GB | Retrieval | Use |
|---|---|---|---|
| Standard | $0.023 | ms | Frequent access |
| Intelligent-Tiering | varies | ms | Unknown patterns |
| Standard-IA | $0.0125 | ms + $0.01/GB fee | Infrequent, 30-day min |
| One Zone-IA | $0.01 | ms + fee | Reproducible data only |
| Glacier Instant | $0.004 | ms | Archive, fast retrieval |
| Glacier Deep Archive | $0.00099 | 12 hr | Cheapest, long-term |
Lifecycle policy automates transitions:
{
"Rules": [{
"Status": "Enabled",
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" },
{ "Days": 365, "StorageClass": "DEEP_ARCHIVE" }
],
"Expiration": { "Days": 2555 }
}]
}
Why would you pick S3 Intelligent-Tiering over just writing a lifecycle policy straight to Standard-IA?
Versioning & Replication
Versioning: Once enabled on a bucket, every PutObject creates a new version. Delete adds a delete marker (object recoverable). Can't disable — only suspend.
Cross-Region Replication (CRR): Async replication to a bucket in another region. Requires versioning on both buckets. Use for: DR, latency (serve from nearest region), compliance (data residency).
Source: us-east-1 bucket (versioning on)
↓ async CRR rule
Destination: eu-west-1 bucket (versioning on)
Same-Region Replication (SRR): Same region, different account or bucket. Use for log aggregation, test/prod data copies.
You delete an object in a bucket with versioning enabled. Is the data gone?
GetObject, but every prior version is still sitting in the bucket and recoverable. You'd have to explicitly delete the specific version (or the delete marker) to actually remove the data. Versioning also can't be disabled once turned on — only suspended.
Access Control
Priority order (most → least specific):
- S3 Block Public Access (account/bucket level — overrides everything)
- Bucket Policy (resource-based, JSON)
- ACLs (legacy, avoid for new setups — AWS recommends disabling ACLs)
- IAM policy (identity-based)
For cross-account access: both the IAM policy on the caller AND the bucket policy must allow. One alone is insufficient.
// Bucket policy: allow specific IAM role from another account
{
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::OTHER-ACCOUNT:role/reader-role" },
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
}]
}
A bucket policy grants read access to a role in another AWS account. That role's own IAM policy back home grants it nothing on S3. Can it read the object?
Presigned URLs
Temporary access to private objects — no IAM credentials required by the recipient:
aws s3 presign s3://my-bucket/report.pdf --expires-in 3600
# Returns: https://my-bucket.s3.amazonaws.com/report.pdf?X-Amz-Signature=...
The URL is signed with the IAM identity's credentials. If the identity's permissions are revoked, existing presigned URLs stop working.
S3 Event Notifications
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
S3["S3: PutObject / DeleteObject"]:::blue
SQS["SQS Queue"]:::orange
SNS["SNS Topic"]:::orange
LAMBDA["Lambda Function"]:::green
EB["EventBridge"]:::blue
S3 -->|event filter by prefix/suffix| SQS
S3 --> SNS
S3 --> LAMBDA
S3 --> EB
EventBridge is the most flexible — fan-out to many targets, content filtering, cross-account delivery.
Multipart Upload
Large files (> 100MB) should use multipart upload — parallel parts, resume on failure:
# AWS CLI handles multipart automatically for files > 8MB
aws s3 cp large-file.tar.gz s3://my-bucket/ --storage-class STANDARD
# Manual multipart (for SDK control)
aws s3api create-multipart-upload --bucket my-bucket --key large-file.tar.gz
# Returns: UploadId
# Upload parts in parallel (each part min 5MB except last)
aws s3api upload-part --bucket my-bucket --key large-file.tar.gz \
--part-number 1 --upload-id <UploadId> --body part1.bin
# Complete (specify all ETags from upload-part responses)
aws s3api complete-multipart-upload --bucket my-bucket --key large-file.tar.gz \
--upload-id <UploadId> --multipart-upload '{"Parts":[{"PartNumber":1,"ETag":"..."}]}'
# Abort (cleanup incomplete uploads — run periodically or set lifecycle rule)
aws s3api abort-multipart-upload --bucket my-bucket --key large-file.tar.gz --upload-id <id>
Lifecycle rule to clean up incomplete multiparts:
{"Rules": [{"Status":"Enabled","AbortIncompleteMultipartUpload":{"DaysAfterInitiation":7}}]}
Object Lock (WORM)
Write-Once-Read-Many — objects cannot be deleted or overwritten for a retention period. Required for compliance (SEC 17a-4, HIPAA).
# Enable Object Lock at bucket creation (cannot add to existing bucket)
aws s3api create-bucket --bucket compliance-logs --object-lock-enabled-for-bucket
# Apply retention to an object
aws s3api put-object-retention \
--bucket compliance-logs \
--key audit-2024-01-15.log \
--retention '{"Mode":"COMPLIANCE","RetainUntilDate":"2031-01-15T00:00:00Z"}'
# COMPLIANCE mode: even root cannot delete before retain date
# GOVERNANCE mode: users with s3:BypassGovernanceRetention CAN delete
s3:BypassGovernanceRetention permission can override it. Good for protecting against accidental deletes.
Under GOVERNANCE mode Object Lock, can an object be deleted before its retention date expires?
s3:BypassGovernanceRetention permission. That's the key difference from COMPLIANCE mode, where no one can delete it early, root included, until the retain date passes.
S3 Select — Query Inside Objects
Query CSV/JSON/Parquet objects with SQL without downloading the whole file:
aws s3api select-object-content \
--bucket my-bucket \
--key logs/2024/01/15.csv.gz \
--expression "SELECT * FROM S3Object WHERE status = '500'" \
--expression-type SQL \
--input-serialization '{"CSV":{"FileHeaderInfo":"USE"},"CompressionType":"GZIP"}' \
--output-serialization '{"CSV":{}}' \
/dev/stdout
# Only the matching rows are transferred — saves bandwidth + cost vs downloading all
S3 Performance
Prefix-based parallelism:
Single prefix: ~3500 PUT/s, ~5500 GET/s
Multiple prefixes: scales linearly
Bad: all files at root (one prefix = bottleneck)
Good: date/hash prefix: 2024/01/15/abc123.parquet (many prefixes)
Transfer Acceleration:
Routes uploads through CloudFront edge locations
Useful when uploading from far geographies (EU → us-east-1)
Enable per bucket, use: mybucket.s3-accelerate.amazonaws.com
~50% faster uploads from distant regions
Byte-Range Fetches:
Download parts of large files in parallel
GET /myfile?Range: bytes=0-1048576 # first 1MB
Application assembles parts → faster for large downloads
All your objects are written with keys like file1.parquet, file2.parquet directly at the bucket root, with no date or hash prefix. What performance problem does that cause at scale?
2024/01/15/abc123.parquet), which scales linearly instead of hitting one shared cap.
RDS & Aurora
RDS Multi-AZ vs Read Replicas
These solve different problems. They're not interchangeable.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
subgraph MultiAZ["Multi-AZ: HA / Failover"]
PRIMARY["Primary DB (AZ-a): reads + writes"]:::blue
STANDBY["Standby DB (AZ-b): synchronous replica"]:::orange
PRIMARY -->|"synchronous replication<br/>(data loss = 0)"| STANDBY
STANDBY -.->|"no traffic — failover only ~60-120s"| WRITER_NEW["New Primary (promoted)"]:::green
end
subgraph ReadReplica["Read Replicas: scale reads"]
MASTER["Primary DB: writes"]:::blue
RR1["Read Replica 1 (same region)"]:::green
RR2["Read Replica 2 (cross-region)"]:::teal
MASTER -->|"async replication<br/>(slight lag)"| RR1
MASTER -->|"async replication"| RR2
end
| Multi-AZ | Read Replica | |
|---|---|---|
| Purpose | High availability / failover | Read scalability |
| Replication | Synchronous | Asynchronous (lag possible) |
| Serves traffic | Standby serves NO traffic | Yes — reads only |
| Failover | Automatic (~60-120s) | Manual promotion |
| Cross-region | No (single region) | Yes |
| Cost | 2x instance cost | Additional instance cost |
You can have BOTH: Multi-AZ for HA + Read Replicas for scale. They're orthogonal.
Multi-AZ failover unfolds over several steps, not instantly — walk through it:
Can a Multi-AZ standby serve read traffic to take load off the primary?
Aurora Architecture
Aurora is not just "managed MySQL/PostgreSQL" — it's a storage-compute separation architecture.
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
subgraph Compute["Compute Layer"]
WRITER["Writer instance (1)"]:::blue
R1["Reader instance 1"]:::green
R2["Reader instance 2"]:::green
end
subgraph Storage["Aurora Distributed Storage: 6 copies across 3 AZs"]
S1["AZ-a copy 1"]:::orange
S2["AZ-a copy 2"]:::orange
S3["AZ-b copy 1"]:::orange
S4["AZ-b copy 2"]:::orange
S5["AZ-c copy 1"]:::orange
S6["AZ-c copy 2"]:::orange
end
WRITER -->|write quorum: 4/6| Storage
R1 -->|read from storage| Storage
R2 -->|read from storage| Storage
WRITER -.->|"failover ~30s<br/>new writer from reader"| R1
Key properties:
- Storage auto-grows in 10 GB increments up to 128 TB — no provisioning
- 6 copies across 3 AZs — can lose an AZ and still write (only needs 4/6 for write quorum, 3/6 for read)
- Readers share the same storage — no replication lag on reads (reads are consistent)
- Failover is faster (~30s) because a reader is already connected to the same storage
Step through an Aurora failover to see why it's faster than RDS Multi-AZ's:
Why does an Aurora reader have no replication lag, unlike an RDS read replica?
Aurora Serverless v2
Scales compute (ACUs — Aurora Capacity Units) instantly in fine-grained increments. Unlike v1 which had cold starts, v2 scales continuously.
Minimum: 0.5 ACU (1 ACU ≈ 2 GB RAM)
Maximum: 128 ACU
Scale up: within seconds on demand
Scale to zero: yes (v2 can pause)
Good for: dev/test environments, irregular workloads, unpredictable spikes.
What's the key operational difference between Aurora Serverless v1 and v2 for a bursty workload?
RDS Proxy
Connection pooler as a managed service — sits between your app and RDS/Aurora.
Problem it solves: Serverless functions (Lambda) open new DB connections per invocation. RDS can handle ~5000 connections max. 1000 concurrent Lambdas = 1000 connections, overwhelming the DB.
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
LAMBDA["Lambda<br/>1000 concurrent invocations"]:::blue
PROXY["RDS Proxy<br/>~50 pooled connections"]:::orange
RDS["RDS / Aurora"]:::green
LAMBDA -->|"1000 connection attempts"| PROXY
PROXY -->|"~50 pooled connections"| RDS
Also provides: IAM authentication for DB connections, automatic failover (no connection string change needed), TLS enforcement.
1000 concurrent Lambda invocations connect through RDS Proxy instead of directly to the database. How many connections does the database itself actually see?
ElastiCache
Managed in-memory caching. Two engines:
| Redis | Memcached | |
|---|---|---|
| Data structures | Strings, hashes, lists, sets, sorted sets, streams | Strings only |
| Persistence | Yes (RDB snapshots + AOF) | No |
| Replication | Yes (primary + replicas) | No |
| Cluster mode | Yes (sharding across nodes) | Yes (hash-based) |
| Pub/Sub | Yes | No |
| Transactions / Lua | Yes | No |
| Use case | Sessions, leaderboards, rate limiting, queues | Simple KV cache, high-throughput read cache |
Rule: Use Redis unless you specifically need Memcached's multi-threaded simplicity or are already on Memcached.
Your app needs pub/sub messaging and wants cached data to survive a node restart. Which ElastiCache engine fits, and why not the other?
Redis Cluster Mode
graph TD
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
subgraph Shard1["Shard 1: slots 0-5460"]
P1["Primary (AZ-a)"]:::blue --> R1A["Replica (AZ-b)"]:::orange
P1 --> R1B["Replica (AZ-c)"]:::green
end
subgraph Shard2["Shard 2: slots 5461-10922"]
P2["Primary (AZ-b)"]:::blue --> R2A["Replica (AZ-a)"]:::orange
end
subgraph Shard3["Shard 3: slots 10923-16383"]
P3["Primary (AZ-c)"]:::blue --> R3A["Replica (AZ-a)"]:::orange
end
16384 hash slots divided across shards. Key k maps to slot CRC16(k) % 16384. Client connects to any node and gets redirected.
A client connects to any node in the Redis Cluster and asks for key "foo". Does that node serve the request directly?
CRC16(k) % 16384. "Connect to any node" doesn't mean any node can serve any key; the slot math decides ownership, not the node you happened to connect to.
Cache-Aside Pattern (Lazy Loading)
func getUser(id string) (*User, error) {
// 1. Check cache
if cached, err := redis.Get(ctx, "user:"+id).Result(); err == nil {
return unmarshal(cached), nil
}
// 2. Cache miss → DB
user, err := db.QueryUser(id)
if err != nil { return nil, err }
// 3. Populate cache, set TTL
redis.Set(ctx, "user:"+id, marshal(user), 5*time.Minute)
return user, nil
}
Write-through (update cache on every write) prevents stale reads but wastes memory caching rarely-read data. Cache-aside is more common.
You switch a service from cache-aside to write-through caching. What did you trade for what?
DynamoDB
Fully managed serverless NoSQL — key-value and document model. No servers, no connections, auto-scales.
Data Model
Every table has a primary key:
- Simple primary key: Partition key only — each item uniquely identified by partition key
- Composite primary key: Partition key + Sort key — items with same PK are sorted by SK, enabling range queries
Table: Orders
PK: customerId (partition key)
SK: orderId#timestamp (sort key)
Query: all orders for customer "123", sorted by time
KeyConditionExpression: customerId = "123" AND begins_with(SK, "order#")
With a composite primary key (partition key + sort key), can two items share the same partition key?
Read/Write Capacity
| Mode | When to use |
|---|---|
| Provisioned | Predictable traffic; set RCU/WCU; can use Auto Scaling or reserved capacity |
| On-demand | Unpredictable spikes; pay per request; ~6x more expensive at steady load |
RCU/WCU: 1 RCU = 1 strongly consistent read of 4 KB/s (or 2 eventually consistent reads). 1 WCU = 1 write of 1 KB/s.
You switch a steady, predictable-traffic table to On-Demand capacity mode to stop worrying about throttling. What did that cost you?
GSI (Global Secondary Index)
Query on non-key attributes — projects a subset of table data with a different partition key.
Table: Orders (PK: customerId, SK: orderId)
GSI: OrdersByStatus
PK: status ← now queryable
SK: createdAt
Projection: ALL
Query: all PENDING orders sorted by time
→ query GSI with status = "PENDING"
GSIs have their own RCU/WCU capacity and can diverge from the main table during heavy writes (eventual consistency).
You write a new item to the base table and immediately query a GSI expecting to find it. Guaranteed?
DynamoDB Streams + Lambda (Event-driven)
graph LR
classDef blue fill:#3498db,stroke:#2980b9,color:#fff
classDef orange fill:#e67e22,stroke:#d35400,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
DDB["DynamoDB Table"]:::blue -->|"change stream<br/>(INSERT/MODIFY/REMOVE)"| STREAM["DynamoDB Stream"]:::orange
STREAM -->|trigger| LAMBDA["Lambda Function"]:::green
LAMBDA -->|"fan-out"| ES["OpenSearch: search index"]:::blue
LAMBDA --> SQS["SQS: async processing"]:::orange
LAMBDA --> SNS["SNS: notifications"]:::orange
Classic CDC pattern inside AWS: changes in DynamoDB propagate to other systems via Streams + Lambda.
S3 vs EBS vs EFS
| S3 | EBS | EFS | |
|---|---|---|---|
| Type | Object storage | Block storage | Network filesystem (NFS) |
| Access | HTTP API, SDK | Mounted on one EC2 (Multi-Attach: up to 16) | Mounted on many EC2 simultaneously |
| Durability | 11 nines (3+ AZ) | Single AZ (snapshot to S3 for backup) | Multi-AZ |
| Max size | Unlimited | 64 TB per volume | Unlimited |
| Latency | ms-level | Sub-ms | Low ms |
| Use case | Static files, backups, data lake, artifacts | OS volumes, DB storage | Shared config, CMS, home dirs |
| Cost | $0.023/GB | $0.10/GB (gp3) | $0.30/GB |
You need one filesystem mounted read-write across 50 EC2 instances at once. Why not just use EBS Multi-Attach?