Cloud and Managed Database Services Questions
Running databases as managed cloud services rather than self-hosting them: choosing and provisioning managed relational and NoSQL offerings (for example Amazon RDS/Aurora/DynamoDB, Azure SQL Database/Cosmos DB, Google Cloud SQL/Spanner), sizing instances and storage, and designing an architecture (read replicas, connection proxies and pooling for managed or serverless compute) to meet a latency or availability target. It covers comparing provisioned versus serverless/autoscaling pricing and operational models, forecasting capacity and cost, and migrating a database onto or between managed deployments: moving a self-hosted database to a managed service, switching a workload from provisioned to serverless (or back), changing a single-AZ deployment to multi-AZ, and the questions to ask before adopting a new managed vendor. The core question is the split of responsibility and cost between the cloud provider and the engineering team: what the provider takes on (patching, infrastructure-level HA, automated backups) versus what remains the team's job (configuration, capacity and cost tuning, security posture like encryption and key rotation). It does not cover backup and restore mechanics, RPO/RTO planning, replication or sharding internals, or SQL-level query diagnostics.
You're designing the managed-database architecture for an ad-serving platform with 100M monthly active users. Characteristics: 95% reads, 5% writes; p95 read latency target < 50ms globally; peak reads 50k RPS; writes are small updates ~10k TPS during peak. Data shape: user preferences and campaign state, schema somewhat flexible. Recommend a managed database architecture (products and components) to meet the latency SLA and discuss caching, indexing, and operational concerns.
Sample Answer
This is a key-value access pattern (look up preferences and campaign state by user or campaign ID) at extreme read scale with a hard global latency target, so the recommendation is DynamoDB as the system of record, fronted by DynamoDB Accelerator (DAX, an in-memory read cache purpose-built for DynamoDB) in each region, replicated across regions with DynamoDB global tables, behind latency-based routing that sends each user to their nearest region.
flowchart LR
Client[Ad-serving client] --> Router[Latency-based routing]
Router --> AppUS[App tier, us-east-1]
Router --> AppEU[App tier, eu-west-1]
AppUS -->|read, cache-first| DAXUS[DAX cluster, us-east-1]
AppEU -->|read, cache-first| DAXEU[DAX cluster, eu-west-1]
DAXUS -->|cache miss| DDBUS[(DynamoDB table, us-east-1)]
DAXEU -->|cache miss| DDBEU[(DynamoDB table, eu-west-1)]
DDBUS <-.async global replication.-> DDBEU
Why this architecture fits the workload
- 95% reads, flexible schema: DynamoDB's item model (no fixed columns per row) fits user preference and campaign-state documents that vary in shape without requiring a schema migration every time a new attribute is added, and it is purpose-built for very high, uniformly-distributed read throughput.
- p95 read latency (the response time that 95% of requests come in at or under; the slowest 5% are allowed to exceed it) under 50ms globally: a single-region database cannot hit this for users far from it; global tables replicate the same table to multiple regions so every region has a local, complete copy, and DAX gives single-digit-millisecond to microsecond reads for cached keys on top of that.
- Writes are small, targeted updates: DynamoDB's per-item write model (not full-row rewrites, not multi-table joins) matches "update this user's preferences" or "update this campaign's remaining budget" well, and 5% writes at 10k TPS peak is comfortably within what a well-partitioned table handles.
Capacity math for the stated peak load
Assume a 1 KB average item size (a compact preference or campaign-state document) and a 90% DAX cache hit ratio for reads, both reasonable for a keyed lookup workload with a lot of repeat access to the same active users and campaigns. DynamoDB prices reads in read capacity units (RCU): a strongly consistent read of an item up to 4KB costs 1 RCU, but an eventually consistent read of the same item costs half that, 0.5 RCU, because DynamoDB can answer it from any replica without first confirming it has the very latest write. A cached preference or campaign-state read here does not need to reflect a write from the last few hundred milliseconds, so eventually consistent reads are the right, cheaper default; strongly consistent reads would double the RCU figures below:
peak_read_rps = 50_000
peak_write_tps = 10_000
dax_cache_hit_ratio = 0.90
RCU_PER_PARTITION = 3000 # AWS-documented per-partition ceiling (read units/sec)
WCU_PER_PARTITION = 1000 # AWS-documented per-partition ceiling (write units/sec)
rcu_per_read = 0.5 # eventually consistent read, item fits in one 4KB chunk
wcu_per_write = 1 # 1KB item fits in one 1KB chunk
backend_reads_per_sec = peak_read_rps * (1 - dax_cache_hit_ratio)
backend_rcu_per_sec = backend_reads_per_sec * rcu_per_read
worst_case_rcu_per_sec = peak_read_rps * rcu_per_read # DAX cold, e.g. right after a deploy
write_wcu_per_sec = peak_write_tps * wcu_per_write
print(f"Backend reads/sec with warm DAX: {backend_reads_per_sec:,.0f}")
print(f"Backend RCU/sec with warm DAX: {backend_rcu_per_sec:,.0f}")
print(f"RCU/sec if DAX is cold: {worst_case_rcu_per_sec:,.0f}")
print(f"WCU/sec (writes, uncacheable): {write_wcu_per_sec:,.0f}")
print(f"Min partitions from writes: {-(-write_wcu_per_sec // WCU_PER_PARTITION):.0f}")
print(f"Min partitions from cold-cache reads: {-(-worst_case_rcu_per_sec // RCU_PER_PARTITION):.0f}")
Backend reads/sec with warm DAX: 5,000
Backend RCU/sec with warm DAX: 2,500
RCU/sec if DAX is cold: 25,000
WCU/sec (writes, uncacheable): 10,000
Min partitions from writes: 10
Min partitions from cold-cache reads: 9
With a warm DAX cache, the DynamoDB table itself only has to absorb roughly 2,500 RCU/sec (well under one partition's ceiling), meaning the table's real sizing constraint here is the write path, which needs throughput spread across at least 10 partitions (10,000 WCU/sec divided by the documented 1,000 WCU-per-partition ceiling). On-demand capacity mode is the right choice at this scale rather than manually provisioned throughput, since the traffic pattern in an ad-serving platform (campaign launches, dayparting) is not perfectly steady, and on-demand removes the need to hand-tune provisioned throughput and auto scaling targets across two regions.
Caching, indexing, and operational concerns
- Partition key design: key user preference items by a well-distributed attribute like
user_id(100M distinct values spreads load evenly). Do not key campaign-state items bycampaign_idalone if a small number of high-spend campaigns dominate traffic; that concentrates writes on a few partitions and creates a hot partition even though the table's aggregate throughput looks fine. Use write sharding (a suffixed key likecampaign_id#shard_n) for any campaign whose write rate alone could approach the 1,000 WCU-per-partition ceiling. - DAX: run a DAX cluster with at least 3 nodes across availability zones (AZs) per region for high availability, and set a write-through or short time-to-live (TTL, how long a cached value is trusted before being refetched) policy on campaign-state items specifically, since a stale campaign budget or targeting rule served from cache for too long has real business cost (overspend, mistargeting), unlike a slightly stale user preference.
- Global tables and consistency: replication between regions is asynchronous and uses last-writer-wins conflict resolution, so a user who is rerouted between regions (failover, or a mobile client crossing a region boundary) can briefly see a slightly older version of their own last write. This is acceptable for preferences and most campaign state, but if a specific field (for example remaining campaign budget) must never double-spend across regions, that field needs a different pattern, for example routing all writes for a given campaign to a single "home" region rather than treating every region as equally writable.
- Monitoring: alarm on
ThrottledRequestsandConsumedWriteCapacityUnitsper table (and per partition-level hot-key indicators), DAX cache hit rate (a real hit ratio far below the assumed 90% means the 2,500 RCU/sec sizing estimate above is wrong and the table needs to absorb more direct traffic), and cross-region replication latency for global tables. - Indexing beyond the primary key: add global secondary indexes (GSIs) only for query patterns that genuinely need them (for example looking up all campaigns for an advertiser), since every GSI is itself a separately-partitioned, separately-throughput-provisioned structure with its own hot-key risk, not a free index the way a relational database's secondary index is.
Design an AWS RDS architecture for a read-heavy, customer-facing application that needs high availability across availability zones and horizontal read scaling. Describe your primary and read-replica placement, automated failover behavior, the connection-routing strategy for read/write separation, how you would handle replica lag, and how you would test failover.
Sample Answer
This architecture needs two structurally different mechanisms working together, not one: a Multi-AZ (availability zone) standby for automated failover, and a separate fleet of read replicas for horizontal read scaling. Conflating them is the most common design mistake here, since a Multi-AZ standby never serves read traffic at all; that is what read replicas are for.
flowchart TB
App[Application tier] --> Router[Read/write router, application- or proxy-side]
Router -->|writes| PrimaryEP[Primary instance endpoint, DNS CNAME]
Router -->|reads, chosen by app/proxy - RDS gives no built-in shared reader endpoint| Replica1[(Read replica, AZ-b)]
Router -->|reads, chosen by app/proxy - RDS gives no built-in shared reader endpoint| Replica2[(Read replica, AZ-c)]
PrimaryEP --> Primary[(Primary, AZ-a)]
Primary -.synchronous, HA only.-> Standby[(Multi-AZ standby, AZ-b)]
Primary -.asynchronous.-> Replica1
Primary -.asynchronous.-> Replica2
Primary and standby placement
The primary runs in one AZ; the Multi-AZ standby runs in a different AZ within the same region, kept current by synchronous replication (the primary does not acknowledge a write until the standby has it too). This standby exists purely for failover, it serves no read traffic under normal operation, which is the key fact that separates it from a read replica. The read replicas are placed across additional AZs (they can share an AZ with the standby; there is no requirement to keep them disjoint), sized in count to the actual read throughput need, addressed below.
Automated failover behavior
On primary failure or an AZ-level impairment, RDS promotes the standby and repoints the DB instance's single existing endpoint (its DNS CNAME, canonical name record) at the newly promoted instance rather than requiring any connection-string change. A classic RDS DB instance has exactly one endpoint total, not a separately named "writer endpoint" the way an Aurora cluster does, so failover here is a DNS re-point of that one address, not a switch between two differently-named endpoints. AWS states this happens "as quickly as 60 seconds" for a single-standby Multi-AZ deployment, with most failovers completing within one to two minutes. The part this leaves in the application's hands: a client with an existing connection to the now-dead primary, and a driver or pool that caches the resolved IP rather than re-resolving DNS promptly, can keep retrying a dead address well past the point RDS considers the failover complete. Short connection timeouts and prompt DNS re-resolution in the connection pool (the application's cache of reusable open database connections) are what actually determine how much of that 60-to-120-second window the application experiences as a real outage.
Connection-routing strategy for read/write separation
Two implementation options, and a fact that has to be stated up front: unlike Aurora, plain RDS does not give you a single, AWS-managed, load-balanced endpoint spanning multiple read replicas. Each RDS read replica is provisioned with, and reachable only through, its own individual endpoint, exactly like any other standalone RDS DB instance. Getting a single logical "read" address, if the application wants one, means building it, either in application code or via a proxy in front of the replica fleet; AWS does not hand it to you for free the way it does for Aurora's cluster reader endpoint.
- App-layer routing: hold the primary's own endpoint for writes, and the individual read replica endpoints for reads, and route each query explicitly in application code (round-robin, random choice, or a fixed per-service assignment across the known replica endpoints). Simplest to reason about, and its failure mode (a write accidentally sent to a read replica) is a loud, immediate error, since replicas reject writes, not a silent correctness bug.
- Proxy-layer routing: a pooler or router (for example a network load balancer or HAProxy in front of the replica fleet, or ProxySQL) that inspects statement type and routes automatically, which also gives the application a single logical address for reads instead of managing the replica endpoint list itself, at the cost of another moving part to operate.
For most teams, start with explicit app-layer routing; it is simpler, its failure mode is safe (a hard error, not silent data drift), and a routing proxy is worth adding later specifically once the number of call sites makes manual discipline unreliable, not as a default from day one.
Handling replica lag
Route only traffic that tolerates some staleness to the reader endpoint, browsing and search-style reads. Any read that must reflect the user's own just-completed write (an order confirmation immediately after checkout is the classic case) should either read from the writer directly for that specific call, or carry a short-lived "read your own write" hint that pins that one request to the writer rather than trusting the load-balanced reader endpoint. Monitor the replica lag metric per replica and treat a replica whose lag exceeds a defined threshold as degraded for routing purposes, since a healthy-but-lagged replica can otherwise keep receiving its normal share of read traffic through the load-balanced endpoint even while serving noticeably stale data.
Testing failover, not just designing for it
Design intent is not validation. Rehearse the actual failover, on a non-production copy at minimum, ideally occasionally against production during a planned low-traffic window once confidence is established:
aws rds reboot-db-instance --db-instance-identifier prod-webapp-db --force-failover
Measure the application's own observed error rate and recovery time during that test, not just the RDS-reported failover duration; those two numbers are not the same thing. A failover that completes on RDS's side in under a minute can still produce several minutes of application-visible errors if the connection pool does not detect the broken connection and re-resolve DNS promptly, which is exactly the gap the "automated failover behavior" section above flags as the application's responsibility, and the only way to know whether that gap is actually closed is to force a real failover and watch what the application does.
Worked example: sizing the read-replica count
peak_read_qps = 40_000
per_replica_sustained_qps = 8_000 # measured comfortable sustained capacity per replica
replicas_for_load = -(-peak_read_qps // per_replica_sustained_qps) # ceiling division
replicas_with_n_plus_1 = replicas_for_load + 1 # tolerate one replica down for maintenance or failure
print(f"Replicas needed to cover peak load: {replicas_for_load}")
print(f"Replicas with N+1 redundancy: {replicas_with_n_plus_1}")
print(f"Per-replica load with one of {replicas_with_n_plus_1} down: "
f"{peak_read_qps/(replicas_with_n_plus_1-1):,.0f} QPS")
Replicas needed to cover peak load: 5
Replicas with N+1 redundancy: 6
Per-replica load with one of 6 down: 8,000 QPS
Five replicas exactly cover the stated peak with zero slack, so losing even one to maintenance or a fault pushes the remaining four over their sustained capacity; provisioning a sixth (N+1) means the fleet still exactly meets sustained capacity, not exceeds it, with one replica out, which is the actual bar "can survive a single replica failure without degrading" sets.
Trade-offs and pitfalls
- Treating the Multi-AZ standby as if it also offloads read traffic is a common misunderstanding that leads teams to under-provision real read replicas; the standby's only job is failover.
- Aurora specifically allows a read replica to also serve as a Multi-AZ failover target if placed in a high-priority promotion tier (tiers run 0 to 15, and tier 0, the lowest number, is the highest priority for promotion, not tier 15), which blurs this line further in Aurora's favor (one fewer idle resource), and Aurora also provides a single load-balanced cluster reader endpoint spanning every Aurora Replica automatically. Neither behavior is something plain RDS read replicas provide: plain RDS replicas are each their own standalone endpoint, with no automatic promotion-to-failover-target and no built-in load balancing across them, which is exactly why a team that wants that behavior for free, rather than building it, often picks Aurora specifically for this reason.
- Skipping the forced-failover rehearsal is the single biggest gap between "designed for high availability" and "actually highly available": the DNS-flip mechanism and the AWS-stated failover time are necessary but not sufficient, since the application's own reconnect behavior is what determines real user-facing downtime.
- Load-balancing reads purely on the reader endpoint without a lag-aware exclusion means a badly lagged but technically healthy replica keeps taking its normal share of traffic, quietly serving stale reads to whichever users happen to be routed to it.
Compare provisioned capacity (reserved throughput) with serverless/autoscaling models for DynamoDB and Aurora Serverless v2. Discuss cost predictability, latency characteristics, cold-start behavior, throttling, operational overhead, and what kind of workload pattern each model suits best.
Sample Answer
Provisioned capacity means you reserve a fixed amount of throughput (how many read or write operations, or how much compute, the system can handle per second) up front and pay for it whether you use it or not; serverless/autoscaling means the service adjusts capacity to match load and you pay closer to actual consumption. The two managed products that expose this choice most directly are DynamoDB (provisioned throughput vs. on-demand) and Aurora (a fixed instance class vs. Serverless v2), and the right choice depends almost entirely on how predictable and how bursty the workload is.
DynamoDB: provisioned throughput vs. on-demand
| Provisioned | On-demand | |
|---|---|---|
| Cost predictability | High: you set read capacity units (RCU) and write capacity units (WCU) and pay a flat rate for them | Lower: billed per request, cost tracks traffic directly, which is predictable only if traffic is predictable |
| Latency | Same single-digit-millisecond latency as on-demand; capacity mode does not change per-request latency | Same latency profile as provisioned |
| Throttling behavior | Requests beyond provisioned capacity are throttled (ProvisionedThroughputExceededException) unless application auto scaling is configured, and auto scaling reacts to a CloudWatch alarm on a delay, not instantly | Scales automatically to serve the traffic, but a sudden spike well beyond the table's recent peak can still see brief throttling until DynamoDB provisions more partitions behind the scenes |
| Operational overhead | You own setting and tuning target utilization for auto scaling, and re-tuning it as traffic patterns shift | Effectively none: no capacity numbers to manage |
| Best workload pattern | Steady, forecastable traffic where the flat provisioned rate beats per-request pricing | Spiky, new, or hard-to-forecast traffic, or a small table where operational simplicity matters more than shaving cost |
Aurora: provisioned instance vs. Aurora Serverless v2
| Provisioned | Serverless v2 | |
|---|---|---|
| Cost predictability | High: fixed instance-class price regardless of load | Variable: billed in Aurora capacity unit (ACU) hours, each ACU is roughly 2 gibibytes (GiB) of memory plus corresponding CPU and networking, measured every second |
| Latency characteristics | Constant, matches the chosen instance class | Same engine, same query latency at a given capacity; latency only changes if the workload outgrows the current ACU level faster than it can scale up |
| Cold-start behavior | None: capacity is always provisioned | None for the normal 0.5 to 256 ACU scaling range, since scaling happens in place (typically the same host) without dropping connections; a cold-start-like delay only appears if you opt into "scale to zero" (available on newer engine versions with a minimum of 0 ACUs) and the cluster has to resume from a fully paused state |
| Throttling | N/A: fixed capacity either handles the load or the instance is CPU/memory saturated | Scales up automatically in increments as small as 0.5 ACU as load increases; a very sudden spike can still see brief elevated latency while capacity catches up, but there is no hard throttling wall the way DynamoDB provisioned mode has |
| Operational overhead | Manual: resizing the instance class is a deliberate, brief-outage operation you schedule | Low: you set a minimum and maximum ACU range once and the service scales within it continuously |
| Best workload pattern | Steady-state OLTP (online transaction processing) load where you can predict peak capacity and want the most predictable bill | Spiky or unpredictable workloads (promotions, seasonal traffic, dev/test environments that sit idle most of the day) where paying for idle provisioned capacity would be wasteful |
Worked example: turning a workload into numbers
DynamoDB. Say a product-catalog table needs to sustain a peak of 800 strongly consistent reads/sec (items averaging 3 KB) and 150 writes/sec (items averaging 1.5 KB). DynamoDB charges 1 RCU (read capacity unit) per strongly consistent read of an item up to 4 KB, and 1 WCU (write capacity unit) per write of an item up to 1 KB, rounding each item up to the next chunk boundary:
import math
peak_reads_per_sec = 800
peak_writes_per_sec = 150
read_item_kb = 3
write_item_kb = 1.5
rcu_per_read = math.ceil(read_item_kb / 4) # 3KB fits in one 4KB chunk -> 1 RCU
wcu_per_write = math.ceil(write_item_kb / 1) # 1.5KB needs two 1KB chunks -> 2 WCU
read_rcu_per_sec = peak_reads_per_sec * rcu_per_read
write_wcu_per_sec = peak_writes_per_sec * wcu_per_write
headroom = 1.2 # 20% buffer above measured peak
provisioned_rcu = math.ceil(read_rcu_per_sec * headroom)
provisioned_wcu = math.ceil(write_wcu_per_sec * headroom)
print(f"Peak RCU/sec needed: {read_rcu_per_sec}")
print(f"Peak WCU/sec needed: {write_wcu_per_sec}")
print(f"Provisioned RCU to reserve (20% headroom): {provisioned_rcu}")
print(f"Provisioned WCU to reserve (20% headroom): {provisioned_wcu}")
Peak RCU/sec needed: 800
Peak WCU/sec needed: 300
Provisioned RCU to reserve (20% headroom): 960
Provisioned WCU to reserve (20% headroom): 360
Under provisioned capacity, you reserve and pay for 960 RCU and 360 WCU every hour of the day, including overnight when actual traffic might fall to 50 reads/sec and 10 writes/sec (50 RCU and 20 WCU, under 6% of what is provisioned). Under on-demand, you are billed only for the RCU and WCU each request actually consumes, so that overnight trough costs proportionally less, at a higher per-unit rate for whichever units you do consume. This is the concrete version of the "idle capacity" trade-off in the table above: provisioned wins when the 800/150 peak is close to the round-the-clock average, on-demand wins when it is not.
Aurora Serverless v2. Say a workload idles near the 1 ACU minimum for 20 hours a day and spikes to 8 ACU for a 4-hour business-hours peak. An Aurora capacity unit (ACU) is billed per second, in ACU-hours:
idle_hours, idle_acu = 20, 1
peak_hours, peak_acu = 4, 8
daily_acu_hours = idle_hours * idle_acu + peak_hours * peak_acu
rate_per_acu_hour = 0.12 # AWS's published Aurora Serverless v2 rate, us-east-1 at time of writing; check current pricing for your region
daily_cost = daily_acu_hours * rate_per_acu_hour
monthly_cost = daily_cost * 30
provisioned_daily_acu_hours = 24 * peak_acu # a fixed instance sized for the peak, running all day
provisioned_monthly_cost = provisioned_daily_acu_hours * rate_per_acu_hour * 30
print(f"Serverless v2 ACU-hours/day: {daily_acu_hours}")
print(f"Serverless v2 monthly compute cost: ${monthly_cost:,.2f}")
print(f"Fixed-at-peak monthly compute cost: ${provisioned_monthly_cost:,.2f}")
Serverless v2 ACU-hours/day: 52
Serverless v2 monthly compute cost: $187.20
Fixed-at-peak monthly compute cost: $691.20
For this bursty, mostly-idle shape, Serverless v2 costs about 73% less than a provisioned instance sized to never fall short at the 4-hour daily peak, because the provisioned instance bills for 8-ACU-equivalent capacity for all 24 hours whether the workload needs it or not. A workload that instead sat near 8 ACU around the clock would flip this: Serverless v2 would bill close to the same 192 ACU-hours/day as the provisioned option, at a higher per-ACU-hour rate, making the fixed instance the cheaper choice.
Trade-offs and pitfalls
- Provisioned capacity is not "safer," it is differently risky: undersized provisioned throughput throttles real user traffic with a hard error, while serverless/on-demand's failure mode is a cost spike or brief latency bump, not a hard rejection. Which failure mode is worse depends on whether the business cares more about a predictable bill or never dropping a request.
- On-demand and Serverless v2 both have a per-unit price premium over well-utilized provisioned capacity. The trade only pays for itself when the workload's peak-to-average ratio is high enough that you would otherwise be paying for a lot of idle provisioned headroom; for a genuinely flat, 24/7 workload, provisioned is usually cheaper.
- Aurora Serverless v2's scale-to-zero option is attractive for dev/test but is a different feature from its normal 0.5 to 256 ACU scaling: only enable it where an occasional resume delay is acceptable, not on anything customer-facing.
- Mixing modes inside one system is a legitimate design, not a compromise: for example, a steady OLTP writer on a provisioned Aurora instance with Serverless v2 read replicas that scale independently to absorb bursty reporting traffic, or a DynamoDB table on provisioned capacity for its steady core traffic with on-demand reserved for a table that only exists to catch spiky, unpredictable event ingestion.
Explain connection pooling and why it matters for managed databases, especially with serverless compute (AWS Lambda) and managed MySQL/Postgres. Name pooling solutions you'd consider, explain how you'd size the pool, and describe what you'd configure to avoid connection storms.
Sample Answer
Connection pooling reuses a small set of already-open database connections across many requests instead of opening a fresh connection (a TCP handshake plus authentication) for every request. It matters most acutely with serverless compute like AWS Lambda because each concurrent Lambda execution environment behaves like an independent short-lived process: if your code opens a database connection on invocation, a burst of concurrent invocations opens that many connections nearly simultaneously, and a managed MySQL or PostgreSQL instance has a hard max_connections ceiling tied to its instance size. A traffic spike that is entirely reasonable in query volume can still take the database down purely on connection count.
Why it matters beyond Lambda
Every open connection costs the database real memory and, for PostgreSQL specifically, a whole backend OS process, so connections are expensive per-unit even when idle. Opening and closing them constantly (rather than reusing a pool) adds latency and CPU overhead from repeated authentication and TLS negotiation on every request, on top of the risk of simply running out of connection slots.
Pooling solutions to consider
- RDS Proxy: AWS-managed, sits between the application and RDS (Amazon's managed relational database service for engines like MySQL and PostgreSQL) or Aurora (AWS's own MySQL- and PostgreSQL-compatible managed database with a different underlying storage engine). It pools and multiplexes connections, supports IAM authentication and credentials via AWS Secrets Manager, and automatically fails over to a standby while preserving application connections. It can only be attached to a writer instance, not a read replica, and it requires the proxy and the database to be in the same virtual private cloud (VPC).
- PgBouncer: self-run, the standard external pooler for PostgreSQL, used constantly in front of Lambda-to-Postgres workloads because of its transaction-mode pooling.
- ProxySQL: the MySQL equivalent, adding query routing and caching on top of pooling.
- Cloud SQL Auth Proxy / connectors: Google Cloud's equivalent pattern for Cloud SQL, handling authenticated, pooled connections from serverless callers.
Sizing the pool
Size the pool from actual concurrency, not guesswork, using Little's Law: the average number of concurrent database connections a workload needs equals its request rate multiplied by the average time each request holds a connection.
L=λ×Wrequests_per_sec = 2000 # peak invocation rate hitting the database
avg_query_duration_s = 0.015 # 15ms average time from "acquire connection" to "commit/release"
concurrent_db_connections_needed = requests_per_sec * avg_query_duration_s
db_max_connections = 1000 # example ceiling for a large Aurora instance class
usable_max_connections = db_max_connections - 50 # reserve headroom for admin/replication
headroom_multiplier = 3 # slack for variance and short bursts above the mean
recommended_pool_size = concurrent_db_connections_needed * headroom_multiplier
print(f"Concurrent DB connections needed on average: {concurrent_db_connections_needed:.1f}")
print(f"Recommended pool size with {headroom_multiplier}x headroom: {recommended_pool_size:.0f}")
print(f"That is {recommended_pool_size/usable_max_connections:.1%} of usable max_connections")
Concurrent DB connections needed on average: 30.0
Recommended pool size with 3x headroom: 90
That is 9.5% of usable max_connections
The point this makes concrete: even at 2,000 requests/sec, if each request only holds a connection for 15ms, the database only ever needs about 30 connections open at once on average, 90 with generous headroom. A pooler lets a huge number of Lambda-side clients share that small, stable set of database-side connections, which is the entire reason pooling and Lambda go together.
What to configure to avoid connection storms
- Use transaction-mode pooling, not session mode. In transaction mode, the database-side connection is returned to the pool the instant a transaction commits, not held for the life of the client's connection to the pooler. This is what lets 30 to 90 real database connections serve thousands of Lambda invocations: each invocation borrows a connection for milliseconds, not for its entire lifetime.
- Point Lambda only at the pooler's endpoint, never at the database engine directly. If any code path bypasses the pooler, it reintroduces the exact one-connection-per-invocation problem pooling exists to solve.
- Set the pooler's client-facing limit high but its database-facing limit bounded near the database's real capacity: thousands of client-side slots, tens to low hundreds of database-side ones.
- Set idle and statement timeouts so an abandoned or slow Lambda-side connection cannot hold a pooled database slot indefinitely.
- Avoid patterns that force "pinning." RDS Proxy (and poolers generally) fall back to a dedicated, unshared connection for a session that changes connection-level state, for example a session-level
SETstatement, an unclosed transaction, or, on RDS Proxy specifically, any statement larger than 16 KB. A pinned session stops multiplexing entirely for its lifetime, which quietly reintroduces the connection-storm risk the pooler was meant to prevent.
Trade-offs and pitfalls
- A pooler is another moving part with its own failure modes: if it goes down, so does every connection through it, so it needs to be highly available in its own right (RDS Proxy handles this for you; a self-run PgBouncer needs its own redundancy plan).
- Transaction-mode pooling breaks session-level features that assume a stable connection: prepared statements that persist across queries, session-level temp tables, and
LISTEN/NOTIFYin Postgres either don't work or behave differently. Audit the application for these before switching modes. - Pooling fixes connection exhaustion; it does not fix a database that is genuinely CPU- or IO-bound. If queries are slow, a pool just queues requests instead of rejecting them outright, which can turn a fast failure into a slow, harder-to-diagnose one if queue depth isn't monitored.
You are migrating an on-prem relational database to a cloud managed service. Create a capacity planning model covering vCPU, memory, IOPS, storage, and cost. Explain how you would measure current workload and project cloud sizing and costs.
Sample Answer
Sizing a migration target is a translation problem, not a forecast: measure the current on-prem workload's actual peak behavior over a representative window, apply a growth margin to that peak (not to the average), map the result to the smallest cloud instance class and storage configuration that clears every dimension with headroom to spare, then price that specific configuration before committing to it.
What to measure, and why peak, not average
Sample OS-level metrics over a representative window, at least a full business cycle, longer if there's a known recurring event like a month-end batch job, using vmstat, iostat, and sar:
- CPU utilization: both the average and the 95th percentile (p95). Sizing off the average alone under-provisions for exactly the periods that determine whether users notice slowness; a database that looks comfortable "on average" but regularly spikes past 70% CPU will still produce real, user-visible latency during those spikes.
- Memory actually used by the database process (its working set), not total system RAM, since most of a box's RAM is often filesystem cache that a managed instance's own buffer pool (the database engine's in-memory cache of recently used data pages) will re-establish differently.
- Read and write IOPS (input/output operations per second, how many individual disk read/write operations the storage can handle each second) at p95.
- Current data volume and its growth rate.
Applying a growth margin, then mapping to an instance class
Apply the growth target (the org's stated figure, or a rate derived from a trailing trend) to the peak figures, not the average, since growth compounds on top of whatever the workload was already spiking to, not just its mean. Then divide by a target utilization ceiling (comfortably below 100%, so the new cloud instance is not immediately re-run at the same tight margin the on-prem box was) to get the actual sizing target:
sampled_cpu_util_p95 = 0.72 # 72% at p95 over the sampling window
onprem_cores = 16
sampled_mem_used_gb = 58 # working-set memory used by the DB process, p95
sampled_read_iops_p95 = 6_500
sampled_write_iops_p95 = 3_200
sampled_data_gb = 850
growth_margin = 1.30 # 30% headroom over 12 months, the org's stated growth target
target_utilization = 0.60 # keep sustained cloud CPU utilization at or below 60%
required_peak_cores = onprem_cores * sampled_cpu_util_p95 * growth_margin
required_vcpu = required_peak_cores / target_utilization
required_mem_gb = sampled_mem_used_gb * growth_margin
required_read_iops = sampled_read_iops_p95 * growth_margin
required_write_iops = sampled_write_iops_p95 * growth_margin
required_storage_gb = sampled_data_gb * growth_margin
print(f"Required vCPU: {required_vcpu:.1f}")
print(f"Required memory: {required_mem_gb:.1f} GiB")
print(f"Required IOPS (read+write): {required_read_iops + required_write_iops:,.0f}")
print(f"Required storage: {required_storage_gb:,.0f} GiB")
# Map to the smallest verified instance class in the chosen family that clears BOTH
# the vCPU and memory requirement, since the two constraints don't always point to
# the same instance size. db.m6g.large/xlarge specs are AWS-published; larger sizes
# follow the family's standard doubling pattern.
instance_classes = [
("db.m6g.large", 2, 8), ("db.m6g.xlarge", 4, 16),
("db.m6g.2xlarge", 8, 32), ("db.m6g.4xlarge", 16, 64),
("db.m6g.8xlarge", 32, 128),
]
print()
for name, vcpu, mem in instance_classes:
fits = vcpu >= required_vcpu and mem >= required_mem_gb
print(f"{name}: {vcpu} vCPU / {mem} GiB -> {'FITS' if fits else 'too small'}")
Required vCPU: 25.0
Required memory: 75.4 GiB
Required IOPS (read+write): 12,610
Required storage: 1,105 GiB
db.m6g.large: 2 vCPU / 8 GiB -> too small
db.m6g.xlarge: 4 vCPU / 16 GiB -> too small
db.m6g.2xlarge: 8 vCPU / 32 GiB -> too small
db.m6g.4xlarge: 16 vCPU / 64 GiB -> too small
db.m6g.8xlarge: 32 vCPU / 128 GiB -> FITS
db.m6g.8xlarge (32 vCPU, 128 GiB) is the smallest class in this general-purpose family clearing both constraints, sized entirely by the vCPU requirement here rather than memory, which is itself a useful signal that this workload is CPU-bound relative to its memory footprint. For storage, provision gp3 (which decouples IOPS and throughput, how much data can be read or written per second, as distinct from IOPS which counts individual operations, from allocated size) to at least the 12,610 combined IOPS and 1,105 GiB figures above, or io2 if the workload's actual latency sensitivity exceeds what gp3 can provide.
Turning the sized configuration into a cost figure
Once the specific instance class and storage configuration are fixed, price that exact configuration through the current AWS Pricing Calculator or the RDS (Amazon Relational Database Service) pricing page for the region and purchasing option actually being used, on-demand, Reserved Instance, or a Savings Plan, rather than remembering or estimating a number, since cloud pricing is region- and time-dependent and changes over time. For a database expected to run continuously for a year or more, the choice between on-demand and a 1- or 3-year commitment is usually a bigger lever on total cost than the instance-class decision itself, and belongs in the same sizing exercise, not as an afterthought.
Trade-offs and pitfalls
- Sizing off average utilization instead of p95 is the single most common sizing mistake, and it fails quietly: the new instance looks fine in a smoke test and then struggles the first time real peak load hits it.
- Do not assume on-prem "cores" map 1:1 to cloud vCPUs without checking; whether the source environment's core counts reflect hyperthreading (a CPU feature that lets one physical core present itself to the OS as two logical cores, so a "core count" can mean physical cores or logical/thread count depending on how it was reported) needs to be verified for the specific source system, not assumed to always be true.
- Skipping the growth margin entirely means re-sizing again almost immediately after cutover, which is disruptive for a live production database; skipping the target utilization headroom (sizing so the new instance sits right at 95%+ p95 on day one) has the same effect for a different reason.
- A capacity plan is an estimate of a moving target built from a finite sampling window, not a certainty; choosing the cheapest instance that "just barely" clears the calculated requirement leaves no room for the estimate itself being wrong, and it usually is, in one direction or the other.
Unlock Full Question Bank
Get access to all 11 Cloud and Managed Database Services interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.