An Empirical Read/Write Benchmark for Per-Tenant SQLite Multi-Tenancy · 50 Concurrent Tenants · In-Memory Cache · Measured Resource Footprint
Version: 2.0.0 · Measured: 2026-08-29 · Mode: ubuntu-docker-wal-per-tenant
- Introduction
- Architecture
- Two Benchmark Suites
- 3.1 Suite A — Original Mixed-Scenario Benchmark
- 3.2 Suite B — Read/Write Split Benchmark
- Results — Suite A
- Results — Suite B
- Micro-Batching Deep Dive
- Cache Performance
- Resource Footprint
- Correctness & Isolation
- How to Re-produce
- Findings & Next Steps
SQLiteTenancy is a multi-tenant e-commerce backend designed around a core principle:
One SQLite database per tenant, with a single serialized writer actor per store.
This guarantees strict tenant isolation (no noisy neighbors) while keeping the hot correctness path (checkout) fully serialized. A memory cache and per-tenant micro-batching amortize the read and write costs respectively.
This document measures the platform with two complementary benchmark suites and reports both performance and resource consumption.
flowchart TB
subgraph Client
U[User / k6]
end
subgraph Docker["Ubuntu 24.04 Docker — sqlite-tenancy:ubuntu"]
A[Actix-Web 4.x · 12 workers]
C[(In-Memory LRU Cache<br/>99.4% hit, TTL 2s)]
R[TenantRouter<br/>HashMap store_id → WriterHandle<br/>LRU 600s / max 100]
subgraph Writer["Per-Tenant Writer Actor"]
Q[mpsc 1024 · warm queue]
T[Interval 8ms]
F[flush_warm()<br/>single TX per batch]
H[do_checkout()<br/>5-stmt TX, serialized]
end
DB[(SQLite per tenant<br/>WAL · NORMAL · FK=ON · 64MB cache)]
end
U --> A
A --> C
A --> R
C -.->|read path| DB
R --> Writer --> DB
A -->|checkout| H
| Component | File | Responsibility |
|---|---|---|
TenantRouter |
src/tenant/router.rs |
Store → writer lookup, LRU eviction, WAL checkpoint |
WriterHandle |
src/tenant/writer.rs |
mpsc queue, cart/checkout/restock operations |
flush_warm |
src/tenant/writer.rs |
batches carts into a single TX (micro-batching) |
do_checkout |
src/tenant/writer.rs |
hot path, 5-statement TX, stock check |
MemoryCache |
src/cache.rs |
LRU + TTL cache for catalog reads |
The platform was measured with two suites that focus on complementary questions.
The first suite models a realistic e-commerce funnel (browse → cart → checkout → restock) across many concurrent tenants. Scenarios A–F probe reads, checkout, cart, flash sale, mixed load, and 100-tenant Zipfian traffic. Full report in BENCHMARK_REPORT_MEASURED.md.
The second suite isolates reads from writes to answer two independent questions:
- How fast can we serve reads? (cache-heavy path)
- How fast can we absorb writes? (per-tenant serialized path)
It runs 4 scenarios (benches/k6/rw/) under a 50-tenant footprint:
| Scenario | Script | VUs | Target | Operation |
|---|---|---|---|---|
| R-S | read_single.js |
100 | store_1 |
GET /products |
| R-A | read_all.js |
1000 | 50 stores (80/20) | GET /products |
| W-S | write_single.js |
100 | store_1 |
POST /cart |
| W-A | write_all.js |
1000 | 50 stores (80/20) | POST /cart |
Both suites run inside the app's Docker network using grafana/k6.
The original mixed benchmark (100 tenants, Ubuntu Docker). Summary of key scenarios:
| Scenario | Load | Throughput | Avg / p95 | Result |
|---|---|---|---|---|
| A Catalog reads | 50 VU / 15s | 2,233 req/s | 22.2ms / 28.7ms | WAL non-blocking |
| C Cart (batched) | 30 VU / 15s | 3,612 req/s | 8.2ms / 9.4ms | Batching enabled |
| E Mixed realistic | 27 VU / 30s | 431 req/s | 2.25ms / 8.77ms p99 | ✅ <25ms |
| F 100-store Zipfian | 82 VU / 120s | ~770 req/s | ~3–5ms | Isolation holds |
| B Checkout | 20 sequential | ~300/s | 13.7ms avg | 5-stmt TX |
| D Flash sale | 100 VU / 30s | 22,945 req/s | 2ms / 4.1ms | 0 oversell |
Suite A validated correctness and isolation (0 oversell across 229k attempts) but measured lower raw throughput because a fresh SQLite connection was opened per read.
After adding the in-memory cache (src/cache.rs), spawn_blocking for DB reads, WAL checkpointing, and raising container nofile, Suite B delivers the platform's true headroom.
| Scenario | VUs | Requests | Throughput (req/s) |
|---|---|---|---|
| R-S Read single store | 100 | 279,280 | 4,653 |
| R-A Read all stores | 1000 | 1,592,202 | 26,517 |
| W-S Write single store | 100 | 120,669 | 1,995 |
| W-A Write all stores | 1000 | 652,484 | 10,632 |
| Scenario | avg | p90 | p95 | max |
|---|---|---|---|---|
| R-S | 0.95ms | 1.19ms | 1.52ms | 72ms |
| R-A | 11.87ms | 21.15ms | 33.34ms | 2.02s |
| W-S | 29.25ms | 12.04ms | 96.66ms | 1.75s |
| W-A | 69.60ms | 54.36ms | 109.68ms | 5.99s |
Reads (cache-backed):
R-S (100 VU) ████████ 4,653 req/s
R-A (1000 VU) ██████████████████████████████████████████████████ 26,517 req/s ← near-linear 5.7x
Writes (serialized per tenant):
W-S (100 VU) ████████ 1,995 req/s
W-A (1000 VU) ██████████████████████████████████████████████████ 10,632 req/s ← 5.3x
Reads scale near-linearly (cache absorbs load). Writes scale but pay per-store single-writer serialization at higher latency.
W-A reported 0.07% failed (468/652,484) — all InsufficientStock (cart quantity accumulated past the seeded prod_1 stock of 5000). Not an oversell; these are legitimate rejection responses.
The write path buffers cart upserts in an 8ms window and flushes them as a single SQLite transaction. Under load this amortizes one fsync across many rows.
| Metric | Suite A (Zipfian low load) | Suite B (high per-store load) |
|---|---|---|
total_flushes |
16,727 | 103,512 |
avg_batch_size |
1.02 | 7.46 |
batch_size_max |
3 | 100 |
pending_peak |
3 | 100 |
by_max (cap 100) |
0 | 813 |
by_timer |
99.7% | 99.2% |
| Store group | Stores | Flushes | Warm enqueued | avg_batch |
|---|---|---|---|---|
| Hot (1–10) | 10 | 30,726 | 642,547 | 20.91 |
| Cold (11–50) | 40 | 72,786 | 130,138 | 1.79 |
| Total | 50 | 103,512 | 772,685 | 7.46 |
The single-store write test (store_1) reached avg_batch 34.30 with pending_peak 100 — empirical proof that micro-batching engages when a tenant is actually busy. The original avg_batch 1.02 was a load artifact (per-store rate too low under Zipfian), not a design flaw.
The MemoryCache (LRU, TTL 2s, 5000 entries) serves catalog reads entirely from RAM.
| Metric | Value |
|---|---|
entries |
50 |
hits |
1,860,728 |
misses |
10,756 |
hit_rate_pct |
99.43% |
At 99.4% hit rate, the SQLite read path is effectively removed from the hot loop — the DB is nearly free under read load.
Measured on the host: AMD Ryzen 5 4600G (12 CPUs), 13 GB RAM, app in a Docker container with nofile=1048576, 12 Actix workers.
| State | CPU (avg / max) | Memory (avg / max) |
|---|---|---|
| Idle | 8.1% | 216 MB |
| Read 1000 VU (26.8k req/s) | 141.8% / 557.9% | 233 MB / 249 MB |
| Write 1000 VU (17k req/s) | 132.7% / 367.7% | 223 MB / 228 MB |
- Memory is remarkably flat (~216–249 MB) across idle, read, and write states — the per-tenant SQLite files live on disk (WAL), and the cache holds only hot product listings. This makes the footprint predictably bounded regardless of load.
- CPU scales with throughput: reads peak at ~5.6 cores (558%) during the 1000-VU burst; writes stay lower (~368%) because the single-writer serialization dominates wall-clock time.
- Reads are cheaper per request than writes: 26.8k req/s at ~142% average CPU vs 17k req/s writes at ~133% — the cache keeps the read CPU mostly in serialization/network rather than disk I/O.
| Direction | Volume |
|---|---|
| Received | ~797 MB |
| Sent | ~2.03 GB |
| Property | Suite A | Suite B |
|---|---|---|
| Flash sale oversell | 0 (50/50, 229k attempts) | 0 |
| Negative stock (any tenant) | 0 | 0 |
| Write failure (Suite B) | — | 0.07% (legit InsufficientStock, not oversell) |
| Hot-store noise on cold-store | none (store_1 686 vs store_38 51) |
none (99.4% cache hit) |
Per-tenant single-writer serialization held at 1000 VU across 50 tenants.
# 1. Build & run the app (Dockerfile adds nofile via docker-compose ulimits)
docker compose build app
docker compose up -d app
# 2. Seed 50 stores (prod_1 stock 5000)
docker exec sqlite-tenancy sh -c 'for i in $(seq 1 50); do
f=/app/data/store_$i.db
[ -f "$f" ] && sqlite3 "$f" "DELETE FROM carts; UPDATE inventory SET stock=5000 WHERE product_id=\"prod_1\";"
done'
# 3. Benchmark Suite B (4 scenarios)
./bench.sh # all 4 (default 60s)
DURATION=20 SCOPE=read ./bench.sh # shorter, read-only
# 4. Suite A (original mixed scenarios)
docker run --rm --network sqlite-tenancy_egyfy-net \
-v $PWD/benches/k6:/scripts grafana/k6 run /scripts/stores_100_mixed.js
# 5. Stats
curl -s http://localhost:8080/stats | jq .Artifacts: benches/k6/rw/*.js (Suite B), benches/k6/*.js (Suite A), src/cache.rs, src/tenant/writer.rs, docker-compose.yml (ulimits).
- ✅ Tenant isolation — per-file SQLite + single-writer actors; no noisy neighbors.
- ✅ Correctness — 0 oversell, serialized checkout.
- ✅ Read scaling — cache makes reads near-free (99.4% hit, 26.8k req/s).
- ✅ Micro-batching — verifiably amortizes fsync under real per-store load (
avg_batch 34.30). - ✅ Bounded memory — flat ~230 MB regardless of load.
- Write latency (
p95 97–110ms) — batch-window + queue wait at 1000 VU. ReduceBATCH_WINDOW_MSor scale write throughput with more per-tenant headroom. - Connection reuse — reads still open a connection on cache miss; a small pool (e.g.
r2d2_sqlite) would harden the miss path. - WAL growth — checkpoint runs every 60s; a soak test (1h+) is needed to validate steady-state disk behavior.
- Larger VU headroom — the container
nofilewas the original 1000-VU bottleneck; with it raised, the next ceiling is CPU (5.6 cores at read peak).
SQLiteTenancy — Multi-Tenant E-Commerce Platform · Rust + Actix-Web + SQLite (WAL)
Measured 2026-08-29 · 50 Tenants · Read/Write Split · In-Memory Cache · Resource Instrumented