Testing the Write and Read Performance of Aurora DSQL from AWS Lambda
This page has been translated by machine translation. View original
Aurora DSQL is AWS's new serverless distributed database. To understand what performance characteristics it shows under real workloads, I measured write and read performance using AWS Lambda.
This article examines the effects of concurrency and physical distance between regions (cross-region), as well as the performance difference between reusing and not reusing DB connections. I also tried to verify the write performance trade-off between single-region and multi-region configurations.
1. Test Environment
| Item | Value |
|---|---|
| Lambda runtime | Node.js 22.x / ARM64 (Graviton) |
| Lambda memory | 1,769MB (equivalent to 1 vCPU) |
| Lambda region | ap-northeast-1 (Tokyo) |
| DSQL clusters | Single-region clusters in ap-northeast-1 (Tokyo) and us-west-2 (Oregon), and a multi-region cluster of Tokyo + Seoul (witness: Osaka) |
| Parallel execution platform | Step Functions Map state (concurrency controlled by MaxConcurrency) |
| Connector | @aws/aurora-dsql-postgresjs-connector |
The test table is defined as follows.
CREATE TABLE perf_test (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
data TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
2. Least-Privilege Design Using IAM Roles
The Step Functions role only has permission to invoke Lambda, and the Lambda role only has permission to connect to DSQL.
Lambda Execution Role Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "dsql:DbConnect",
"Resource": [
"arn:aws:dsql:ap-northeast-1:<account-id>:cluster/<cluster-id-tokyo>",
"arn:aws:dsql:us-west-2:<account-id>:cluster/<cluster-id-oregon>"
]
}
]
}
dsql:DbConnect is the action that allows connecting as a regular user. Limiting Resource to the ARNs of specific clusters also prevents connections to other clusters.
Linking the DB Role and IAM
-- Connect as admin and run
CREATE ROLE bench_user WITH LOGIN;
AWS IAM GRANT bench_user TO 'arn:aws:iam::<account-id>:role/<lambda-role-name>';
GRANT USAGE ON SCHEMA public TO bench_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO bench_user;
AWS IAM GRANT is DSQL-specific syntax that links an IAM role ARN with a DB role. This setting is required for each cluster (in a multi-region configuration, it is propagated automatically). GRANT ... ON ALL TABLES is only effective for tables that exist at the time of execution. Since DSQL does not support ALTER DEFAULT PRIVILEGES, GRANT had to be run again whenever a table was added.
3. Lambda Function Implementation
import { auroraDSQLPostgres } from "@aws/aurora-dsql-postgresjs-connector";
const ENDPOINTS = {
"ap-northeast-1": process.env.DSQL_ENDPOINT_TYO,
"us-west-2": process.env.DSQL_ENDPOINT_PDX,
};
let cachedSql = null;
let cachedRegion = null;
function getSql(region) {
if (cachedSql && cachedRegion === region) return cachedSql;
cachedSql = auroraDSQLPostgres({
host: ENDPOINTS[region],
username: "bench_user",
});
cachedRegion = region;
return cachedSql;
}
export const handler = async (event) => {
const op = event.op || "insert";
const region = event.region || "ap-northeast-1";
const cache = event.cache !== false;
const start = Date.now();
const sql = cache
? getSql(region)
: auroraDSQLPostgres({ host: ENDPOINTS[region], username: "bench_user" });
try {
if (op === "insert") {
const id = crypto.randomUUID();
await sql`INSERT INTO perf_test (id, data) VALUES (${id}, ${"bench"})`;
} else if (op === "select") {
await sql`SELECT id, data FROM perf_test LIMIT 1`;
}
const ms = Date.now() - start;
if (!cache) await sql.end();
return { ok: true, op, region, cache, ms };
} catch (e) {
const ms = Date.now() - start;
if (!cache) try { await sql.end(); } catch {}
return { ok: false, op, region, cache, ms, error: e.message?.slice(0, 200) };
}
};
cachedSql is kept in a global variable so the connection is reused on warm starts. Because the official connector transparently generates and refreshes IAM authentication tokens, there is no need to manage token expiration when reusing connections.
For comparison, I also prepared an implementation that connects every time when false is specified for event.cache.
4. Effect of Connection Reuse
Concurrency 1
This is a comparison between no reuse (new DB connection each time) and reuse (DB connection kept).
| Pattern | Without reuse | With reuse |
|---|---|---|
| Tokyo→Tokyo INSERT | 222ms | 21ms |
| Tokyo→Tokyo SELECT | — | 6ms |
| Tokyo→Oregon INSERT | 1,512ms | 106ms |
| Tokyo→Oregon SELECT | — | 102ms |
Without reuse, a TCP/TLS handshake and IAM token generation occur every time. Especially in cross-region cases, the RTT is large, so the impact is amplified by the number of round trips in the handshake, and the Tokyo→Oregon INSERT takes 1,512ms. With reuse, the Tokyo→Tokyo INSERT drops to 21ms, and even Oregon drops to 102–106ms, which almost matches the pure RTT.
Parallel Execution (Without Reuse)
This configuration runs Lambdas in parallel with a Step Functions Map state, with each Lambda connecting to DSQL and performing an operation once.
Tokyo→Tokyo
| Concurrency | op | med(ms) | p95(ms) | rps |
|---|---|---|---|---|
| 50 | INSERT | 162 | 612 | 28.3 |
| 100 | INSERT | 105 | 518 | 44.7 |
| 200 | INSERT | 95 | 263 | 48.7 |
| 50 | SELECT | 88 | 118 | 53.8 |
| 100 | SELECT | 88 | 143 | 48.8 |
| 200 | SELECT | 90 | 133 | 53.4 |
Without reuse, rps plateaus at around 50.
Tokyo→Oregon
| Concurrency | op | med(ms) | p95(ms) | rps |
|---|---|---|---|---|
| 50 | INSERT | 1,338 | 1,473 | 21.9 |
| 100 | INSERT | 1,188 | 1,473 | 24.3 |
| 200 | INSERT | 1,165 | 1,408 | 27.3 |
| 50 | SELECT | 1,064 | 1,301 | 24.4 |
| 100 | SELECT | 1,062 | 1,302 | 27.8 |
| 200 | SELECT | 1,057 | 1,125 | 28.3 |
For Oregon, the Tokyo–Oregon RTT (about 100ms) multiplied by the number of handshake round trips is added, pushing med above 1,000ms.
Parallel Execution (With Reuse)
Next are the results with connection reuse.
Tokyo→Tokyo
| Concurrency | op | med(ms) | p95(ms) | rps |
|---|---|---|---|---|
| 50 | INSERT | 10 | 12 | 48.4 |
| 100 | INSERT | 10 | 12 | 58.1 |
| 200 | INSERT | 9 | 11 | 63.3 |
| 50 | SELECT | 4 | 5 | 64.0 |
| 100 | SELECT | 4 | 5 | 63.9 |
| 200 | SELECT | 4 | 5 | 66.4 |
Tokyo→Oregon
| Concurrency | op | med(ms) | p95(ms) | rps |
|---|---|---|---|---|
| 50 | INSERT | 106 | 1,487 | 36.4 |
| 100 | INSERT | 106 | 107 | 62.3 |
| 200 | INSERT | 105 | 107 | 60.0 |
| 50 | SELECT | 100 | 102 | 62.3 |
| 100 | SELECT | 100 | 102 | 74.5 |
| 200 | SELECT | 100 | 101 | 69.6 |
With connection reuse, Tokyo→Tokyo INSERT improved to a med of 9–10ms and SELECT to 4ms. Oregon also dropped to a med of 100–106ms, which is pure RTT only.
5. Write Throughput
A single Lambda sequentially INSERTed 1,000 rows, and I ran this while varying the concurrency. The configuration is Tokyo→Tokyo with connection reuse. Each concurrency level was run sequentially to eliminate interference from simultaneous execution.
| Concurrency | Total rows | wall(s) | Total rps | Single Lambda rps |
|---|---|---|---|---|
| 1 | 1,000 | 9.7 | 103 | 105 |
| 10 | 10,000 | 10.0 | 1,002 | 111 |
| 20 | 20,000 | 9.8 | 2,048 | 113 |
| 30 | 30,000 | 9.8 | 3,065 | 117 |
| 40 | 40,000 | 10.2 | 3,905 | 116 |
| 50 | 50,000 | 10.0 | 4,996 | 116 |
| 100 | 100,000 | 20.0 | 5,000 | 117 |
INSERT throughput of a single Lambda is stable at about 115 rps, and it scales almost linearly with parallelization. It reached about 5,000 rps at 50 concurrency. The total rps not growing at c=100 is due to internal batch splitting in the Step Functions Map state, and the DSQL-side limit has not been reached.
6. Single vs Multi-Region
I checked the performance difference for INSERT / SELECT between single-region and multi-region (Tokyo + Seoul, witness: Osaka). I created two sets of clusters and measured each to confirm reproducibility. Lambda connects from Tokyo to the Tokyo endpoint.
Cluster creation time
| Configuration | Time required |
|---|---|
| Single-region | About 8 seconds |
| Multi-region | About 100–115 seconds |
Multi-region requires 3 steps (creating a cluster in each region + mutual peering), and it takes about 2 minutes to become ACTIVE.
INSERT (with reuse, Tokyo→Tokyo)
| Concurrency | Single med(ms) | Multi med(ms) | Ratio |
|---|---|---|---|
| 50 | 10 | 65 | 6.5x |
| 100 | 10 | 64 | 6.4x |
| 200 | 9 | 64 | 7.1x |
SELECT (with reuse, Tokyo→Tokyo)
| Concurrency | Single med(ms) | Multi med(ms) | Ratio |
|---|---|---|---|
| 50 | 4 | 4 | 1.0x |
| 100 | 4 | 4 | 1.0x |
| 200 | 4 | 4 | 1.0x |
INSERT became about 6–7 times slower with multi-region. This is because, to guarantee strong consistency, synchronization overhead between regions occurs at commit time in multi-region. On the other hand, SELECT completes within the local region, so there is no difference.
7. Summary
This was a small-scale test using simple INSERT / SELECT against a single table, but it clarified DSQL's performance characteristics and the key points when using it from a serverless environment.
The most important point is reusing DB connections. With reuse, INSERT takes 10ms or less and SELECT takes 4–6ms, a 10–20x improvement compared to connecting every time. I was also able to confirm that write throughput scales almost linearly from a single Lambda (about 115 rps) to 5,000 rps at 50 concurrency.
In addition, in a multi-region configuration, INSERT becomes about 6–7 times slower due to the synchronization overhead that guarantees strong consistency. On the other hand, SELECT performance, which is a local read, does not change. Be careful about the trade-off between availability and write performance.
An important caveat is DSQL's connection rate limit (100 connections/second, a hard limit that cannot be relaxed). When putting this into production from Lambda, connection reuse should be a prerequisite, and to avoid a concentration of cold starts during spikes, using Provisioned Concurrency and controlling concurrency are essential.
In cases where high throughput is required in multi-region or cross-region setups, it is important to devise ways to reduce direct access to DSQL. For writes, asynchronous bulk processing that buffers with SQS or similar is effective. For reads, since you cannot rely on read replicas, please consider a cloud-native overall design, such as application-side caching or page caching with CloudFront.
