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.
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 deciding whether to move the primary database to a serverless managed database or keep it on a self-managed cluster running on VMs or containers. Discuss the connection-scaling issues serverless callers create, the proxy patterns you would consider (for example connection pooling or RDS Proxy), cold-start interactions, cost elasticity, operational overhead, and migration complexity.
Sample Answer
Serverless callers break the assumption self-managed database sizing quietly relies on: that a bounded, known number of long-lived application processes hold a bounded, known number of connections. A serverless caller like AWS Lambda instead can spin up hundreds or thousands of short-lived execution environments during a burst, and neither a self-managed cluster's fixed max_connections nor a naively provisioned serverless database survives that without a connection proxy in the path. The recommendation: put a proxy (RDS Proxy, a self-run PgBouncer or PgCat in transaction mode, or Cloud SQL's connector) in front of the database and treat it as a required architectural component, not an optional extra, whichever database you choose; with that in place, a serverless-capable managed database wins over a self-managed cluster for this specific caller shape on every axis except raw steady-state infrastructure cost, which is rarely the deciding factor once the operational cost of getting connection scaling wrong is counted.
Why serverless callers exhaust connections
Each Lambda execution environment behaves like its own short-lived process. A warm environment can reuse a connection across invocations for as long as it stays alive, but a burst of traffic spins up many new environments simultaneously, and each one that opens its own database connection on cold start has no shared, persistent pool the way a long-running application server naturally has, one pool, shared across every request that server handles. The result is that concurrency at the compute layer translates almost one-to-one into concurrent connection demand at the database layer, a relationship a traditional fixed-process application server never creates in the first place.
Proxy-pattern mitigations
- RDS Proxy: sits between the application and RDS/Aurora, pools and multiplexes connections, and is the AWS-managed default for this pattern.
- PgBouncer or PgCat in transaction mode: the self-hosted equivalent for PostgreSQL, releasing the real database connection back to the pool the instant a transaction commits rather than holding it for a client's whole session.
- Cloud SQL Auth Proxy / connectors: the equivalent authenticated, pooled path for Cloud SQL.
All three convert "many short-lived execution environments each wanting a connection" into "a small, bounded number of real database connections," which is what actually lets a serverless caller survive a real traffic burst, regardless of whether the database itself is self-managed or serverless.
Cold-start interactions
Two separate cold starts can stack unfavorably: the caller's own cold start (a new Lambda execution environment initializing, which is also where its first database or proxy connection gets established) and, if the database side is also serverless with scale-to-zero enabled, the database's own resume-from-paused delay. A request that happens to hit both a cold caller and a paused database pays both penalties in sequence. For a workload built on serverless callers, the practical mitigation is to keep the database side warm, a non-zero minimum capacity rather than scale-to-zero, even though that gives up some of serverless's cost-elasticity benefit; the alternative is a compounding latency tail on exactly the requests that are already least predictable.
Cost elasticity and operational overhead
A serverless database paired with serverless callers gives the cleanest cost-tracks-actual-usage story, genuinely valuable for spiky or unpredictable traffic. A self-managed cluster on fixed virtual machines (VMs) has no elasticity at all: the provisioned cluster is paid for whether traffic shows up or not. But "serverless removes operational work" is only half true. Self-managed adds real, ongoing operational work (patching, high availability, backups) independent of the caller side; a serverless database removes most of that, but introduces a new, smaller operational surface that did not exist before: proxy capacity planning and monitoring, minimum-capacity and cold-start tuning, and a connection-storm incident response playbook for the cases the proxy does not fully absorb.
Migration complexity is not just an infrastructure change
Adopting this pattern typically requires application-side changes, not only new infrastructure:
- Connection timeout and retry logic has to change. A connection attempt might now be queued or briefly rejected by the proxy under load, instead of always succeeding immediately the way a warm pool on a long-running app server does, so retry-with-backoff logic that previously did not exist becomes necessary.
- Anything relying on session-level state breaks under transaction-mode pooling and has to be found and rewritten, not just reconfigured: prepared statements assumed to persist across calls, session variables, or advisory locks held across requests.
- Cutover needs the same phased, rollback-capable approach as any other production database change, not a configuration flag flip, since connection behavior under real burst load is exactly the kind of thing that is hard to fully validate outside production traffic.
Worked example: what the proxy is actually absorbing
proxy_db_side_pool = 50 # real database connections the proxy maintains
concurrent_lambda_burst = 500 # simultaneous execution environments during a spike
immediately_served = min(proxy_db_side_pool, concurrent_lambda_burst)
queued = max(0, concurrent_lambda_burst - proxy_db_side_pool)
print(f"Requests served immediately from the pool: {immediately_served}")
print(f"Requests queued at the proxy, waiting for a connection to free up: {queued}")
print(f"Fraction queued: {queued/concurrent_lambda_burst:.0%}")
Requests served immediately from the pool: 50
Requests queued at the proxy, waiting for a connection to free up: 450
Fraction queued: 90%
Without a proxy, a database with a hard connection ceiling near 50 would simply reject most of those 500 simultaneous connection attempts outright. With the proxy in front, 450 of them queue briefly instead, each waiting only as long as the transaction ahead of it in line takes, typically a short OLTP (online transaction processing) transaction. That difference, hard rejection versus a brief queue, is the entire value the proxy adds, and it is exactly what neither a bare self-managed cluster nor a bare serverless database provides on its own.
Trade-offs and pitfalls
- Treating the proxy as optional, "we'll add it if connections become a problem," is the most common mistake here: connection exhaustion under a real burst is a hard failure mode, not a gradual degradation, so it needs to be designed in up front, not bolted on after an incident.
- Keeping the database warm to avoid stacked cold starts partially undercuts the cost-elasticity argument for going serverless in the first place; that is a real trade to make consciously, not a free win.
- Underestimating the application-side rework (retry logic, removing session-state dependencies) as "just an infrastructure migration" is how these migrations run over their planned timeline; budget for it as application work, not as a deployment configuration change.
Explain read replicas in managed database offerings: what they are, how they work in services like Amazon RDS/Aurora or Cloud SQL, what operational problems they solve, and what limitations or risks you'd want to watch for when relying on them.
Sample Answer
A read replica is an asynchronous, read-only copy of a primary database. The primary streams its stream of committed changes (the binary log in MySQL, the write-ahead log, or WAL, in PostgreSQL) to one or more replicas, which replay those changes to stay current. The replica accepts read queries but never writes, and it is always at least slightly behind the primary because replication is asynchronous: the primary does not wait for the replica before acknowledging a write.
How it works in managed offerings
- Amazon RDS (MySQL/PostgreSQL/MariaDB): uses the engine's own native replication mechanism, binlog-based for MySQL/MariaDB, WAL streaming for PostgreSQL, to ship changes to up to several read replicas per primary. Each replica is a fully separate instance with its own storage and its own endpoint.
- Amazon Aurora: replicas share the same underlying cluster storage volume as the writer instead of copying data over the network, so replica lag is typically much lower than RDS's engine-native replication under normal load, low enough that AWS markets Aurora's replicas as "low-latency." Aurora also lets a replica be promoted automatically as part of Multi-AZ failover (Multi-AZ means the provider keeps a synchronized standby copy of the database in a second availability zone; failover is automatically switching traffic to that standby, or here, to a promoted replica, when the primary fails), which RDS's plain read replicas cannot do.
- Google Cloud SQL: replicates asynchronously per-transaction from the primary to one or more read replicas, including optional cross-region replicas, using the same engine-native mechanisms under the hood.
In all three, the replica is provisioned and monitored by the provider (you don't manage the replication process by hand), but the replication itself is still asynchronous and lag is not bounded to zero.
What problems read replicas solve
- Read scaling: route SELECT-heavy traffic to one or more replicas so it does not compete with writes for CPU and I/O on the primary.
- Workload isolation: point a specific class of query, for example an analytics dashboard or a nightly reporting job, at a dedicated replica so a slow or expensive query can't degrade the primary's write latency.
- Reduced primary load and latency: fewer connections and less query volume on the primary generally means more headroom and lower p95/p99 latency (the response time for the slowest 5% and slowest 1% of requests, a way of measuring worst-case performance rather than the average) for the writes and reads that must hit the primary.
- A promotable target: an RDS read replica (not a Multi-AZ standby) can be manually promoted to a standalone writable instance, which is useful for cross-region disaster recovery or for splitting off a subset of traffic to its own database.
Limitations and risks to watch
- Replication lag is not bounded. Under normal load it can be small enough to ignore; under a write burst, a long-running DDL (data definition language, for example an ALTER TABLE) statement, or a large batch job on the primary, it can grow to seconds or minutes, and the replica has no way to signal "I'm behind" to a client unless you build that check yourself.
- Eventual consistency, not read-your-writes. A client that writes to the primary and immediately reads from a replica can see stale data, or even see the write "disappear" if it lands on a replica that hasn't caught up yet. This is the single most common production bug with read replicas: a user submits a form, the confirmation page reads from a replica, and the user's own just-written data appears missing.
- No cross-replica consistency guarantee. Two replicas of the same primary can be at different points in the replication stream at the same instant, so two requests hitting different replicas can see different, mutually inconsistent snapshots.
- Promotion is a manual, disruptive operation, not an automatic failover mechanism. Promoting a read replica breaks its replication link permanently, and every other reader and writer in the application needs to be repointed. This is a materially different (and slower) recovery path than a Multi-AZ standby failover.
- Real added cost. A read replica is a full running instance, not a lightweight cache, so it roughly multiplies your compute bill by the number of replicas you run.
Worked example: how a write burst turns into visible lag
Assume a primary normally emits 12 MB/s of write-ahead log under steady state, a replica can apply changes at 20 MB/s, and a 10-minute batch job pushes the primary's write rate to 30 MB/s.
backlog growth rate during burst=30−20=10 MB/sOver the 600-second burst that backlog reaches 10×600=6,000 MB of unreplayed write-ahead log sitting on the replica the instant the burst ends.
It is tempting to convert that 6,000 MB straight into a "seconds behind" figure by dividing by the replica's apply rate (6,000 / 20 = 300 seconds), but that is not the number a real replication-lag metric would show at that instant, and it is a common mistake when reasoning about replication under load. What tools like MySQL's seconds_behind_master or Aurora/RDS's ReplicaLag CloudWatch metric actually report is how old the transaction the replica is currently replaying is, which you get by comparing cumulative bytes applied against cumulative bytes produced, not backlog divided by capacity. At the moment the burst ends (600 seconds in), the replica has applied 20×600=12,000 MB while the primary has produced 30×600=18,000 MB since the burst started. The 12,000 MB the replica has applied so far is data the primary had already produced after just 12,000/30=400 seconds of the burst, so the replica is 600−400=200 seconds, about 3 minutes 20 seconds, behind at the instant the burst ends, not 300 seconds.
Lag keeps growing for a while even after the burst is over, because the replica is still capacity-constrained at 20 MB/s working through the backlog while the primary keeps producing new data (now at the lower 12 MB/s rate) that pushes "how old is what I'm applying right now" further out. Lag peaks about 5 minutes after the burst ends, at 300 seconds (5 minutes) behind, then shrinks back toward zero as the replica keeps applying at 20 MB/s against a primary now producing only 12 MB/s, a net drain rate of 20−12=8 MB/s. The 6,000 MB backlog itself (still sitting there right when the burst ends) fully drains 6,000/8=750 seconds, 12.5 minutes, after the burst ends, which is also when lag finally returns to zero.
Total elapsed time with a nonzero backlog and elevated lag: the 600-second burst plus the 750-second drain, 1,350 seconds, 22.5 minutes, not the 17.5 minutes you get from naively adding a 300-second instant-lag figure to the drain time. Lag grows for the first 900 seconds (15 minutes: the full 10-minute burst plus 5 more minutes while the primary is still outpacing the still-catching-up replica), peaks at 5 minutes behind, then shrinks over the remaining 450 seconds (7.5 minutes). For that whole 22.5-minute window, anything reading from that replica was looking at data that was seconds to minutes old. This is exactly the kind of window where a "read after write" bug shows up, and it is why replica lag needs to be an explicit, monitored metric (RDS and Aurora both expose it as a CloudWatch metric) rather than an assumption, or worse, a hand calculation done wrong under incident pressure.
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.
Describe how you would deploy a managed relational database for a production web application. Cover service selection (RDS/Aurora/Cloud SQL/Managed MySQL), sizing (instance class, storage type), read scaling, encryption, maintenance windows, and routine maintenance practices.
Sample Answer
Deploying a managed relational database for production is five deliberate decisions, not one "create instance" click: which service, what size, whether you need read scaling yet, encryption configured before first write, and a maintenance window you chose rather than accepted by default.
Service selection
- Amazon RDS: the broadest engine choice (MySQL, PostgreSQL, MariaDB, SQL Server, Oracle, Db2), engine-native replication, and the most flexible instance sizing including small burstable classes. The default choice for most web applications unless you have a specific reason to want more.
- Amazon Aurora: MySQL- and PostgreSQL-compatible, with storage-layer replication instead of engine-level replication, which gives materially faster failover (automatically switching to a healthy standby when the primary fails) and a higher write-throughput (the volume of write operations the database can sustain per second) ceiling on the largest instance classes. Worth the higher baseline cost when you expect to outgrow a single well-tuned RDS instance, or when failover speed matters more than for a typical web app.
- Cloud SQL: Google Cloud's equivalent to RDS for MySQL, PostgreSQL, and SQL Server, same category of decision as RDS on AWS.
For a typical production web application starting out, RDS or Cloud SQL on the engine your team already knows is the right default; reach for Aurora specifically when you can point to a concrete scaling reason, not by default.
Sizing: instance class and storage type
Pick the instance class from measured or realistically estimated CPU and memory need with headroom, not a round number picked for comfort. Storage type matters as much as instance class:
- gp3 (general purpose SSD): the right default for most online transaction processing (OLTP) workloads. It decouples provisioned IOPS (input/output operations per second: the count of individual read or write operations the volume can handle each second, distinct from throughput's raw bytes-per-second measure) and throughput from allocated storage size, so you are not forced to over-allocate storage just to get more IOPS.
- io2 / io2 Block Express: for latency-sensitive, very high-IOPS workloads where gp3's ceiling is not enough; costs more per provisioned IOPS.
- Enable storage autoscaling so unplanned data growth raises allocated storage automatically instead of the instance running out of space and rejecting writes.
Read scaling
Add a read replica only when there is a specific, measured reason: reporting or analytics queries competing with transactional traffic for CPU and I/O, or read throughput that a single writer genuinely cannot serve even with caching in front of it. A read replica is a full additional instance (real cost, real operational surface via replication lag, the delay before a write on the primary becomes visible on the replica), not a free performance knob, so route only the traffic that needs it, typically through RDS Proxy or explicit read/write splitting in the application, rather than defaulting every read query to a replica.
Encryption
Enable storage encryption at creation time: RDS cannot add encryption to an existing unencrypted instance in place, only by snapshotting it, copying the snapshot with encryption enabled, and restoring from that copy, so getting this right on day one avoids a disruptive migration later. The AWS-managed key (which AWS automatically rotates every year) is sufficient for most applications; use a customer-managed KMS (Key Management Service) key only when you need explicit control over key policy or rotation cadence, for example a compliance requirement to control who can disable access to the key. Separately, enforce TLS (transport layer security) on connections at the engine level, rds.force_ssl for PostgreSQL or require_secure_transport for MySQL, since encryption at rest alone does not protect credentials and query data in flight between the application and the database.
Maintenance windows and routine maintenance
If you do not set a maintenance window explicitly, RDS assigns a random 30-minute window in your region, which may land during business hours. Choose one deliberately, during genuinely low-traffic hours for your actual user base, and prefer applying non-urgent changes during that window rather than immediately, so patches land predictably instead of mid-peak-traffic. Keep automatic minor-version upgrades on for security patches, but review major version upgrades manually since those can carry breaking changes to the engine's behavior. As routine practice: review the instance's "pending maintenance actions" on a regular cadence rather than being surprised by them, audit the parameter group for drift from intended settings after any manual tuning, and revisit instance rightsizing on a schedule (for example quarterly) instead of only reacting once performance has already degraded.
Worked example: a concrete creation command
aws rds create-db-instance \
--db-instance-identifier prod-webapp-db \
--engine postgres --engine-version 16.4 \
--db-instance-class db.r6g.xlarge \
--allocated-storage 200 --storage-type gp3 --iops 6000 --storage-throughput 250 \
--max-allocated-storage 500 \
--storage-encrypted --kms-key-id alias/prod-db-key \
--preferred-maintenance-window "sun:05:00-sun:06:00" \
--auto-minor-version-upgrade
This encodes the sizing decision (an r6g.xlarge, memory-optimized Graviton instance class, a common default for a moderate production web app), gp3 storage with explicit IOPS and throughput rather than whatever the default happens to be, storage autoscaling up to 500 GiB so growth does not hit a hard wall, encryption specified at creation with a named key, a chosen (not default) maintenance window, and automatic minor-version patching. Multi-AZ and backup retention are deliberately left off this example: they are availability and disaster-recovery decisions with their own trade-offs, not part of the base provisioning choice this question is about.
Trade-offs and pitfalls
- Defaulting to the largest instance class "to be safe" wastes money every hour it runs oversized; defaulting to the smallest "to save money" invites a production incident the first time traffic grows. Size from data, and revisit it, rather than picking once and forgetting.
- Adding a read replica before you have a measured reason adds real operational surface (monitoring replica lag, routing logic) for no benefit, and it is easy to add one reflexively because "that's what scaling a database looks like."
- Forgetting to set storage encryption before the first write is the single most expensive mistake in this list to fix later, since fixing it means a snapshot-copy-restore cycle on a live database rather than a config change.
- Accepting the default maintenance window is a common, easily avoided source of an unplanned mid-day blip; it costs nothing to set deliberately at creation time.
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.