31 Dec 2023
How fast is SQLite?
Plenty fast
I have recently been on a path towards radical simplification of the systems I build. Forgoing Spring Boot for a simple Axum Rust backend is a simple choice for me. Choosing a database is an entirely different story. I have been using PostgreSQL successfully, but operating it has been a bit of a black box. Sure, I just spin up a standard docker container and it works, but I wouldn't want to start straying from the default configuration considering the absolute wealth of features and manual pages there are. So I started looking into SQLite.
Advantages
SQLite is an embedded database, a library if you will. Compared to dedicated DB servers that need to be operated separately, this simplifies the deployment significantly. Additionally, the latency of queries is extremly low, as they are more function call than networked request. For incremental backups, there is the excellent Litestream project that observes the write-ahead log and pushes changes up to an S3 compatible object storage. Right up my alley.
Performance
I conducted some microbenchmarks to gauge the performance of SQLite. There is one table that is declared as follows:
You might notice that the motivating application to try SQLite is a home-grown metrics
collection system. For one scenario I also add indices on bucket
and date
. Next,
I ran a bunch of (hopefully) self explanatory scenarios and measured the execution time of the
queries in isolation. Read queries are prepared statements of the form:
Notice that rowid
is an implicit column added by SQLite. Write queries:
The benchmarks were run on my local macbook, as well as a linux ARM server and a linux x86 server on Hetzner.
Results
Scenario | Throughput | 90 percentile duration |
---|---|---|
Vanilla (read) | 183'150/s | 6us |
Vanilla (write) | 4'363/s | 248us |
WAL Mode (read) | 454'338/s | 3us |
WAL Mode (write) | 14'401/s | 37us |
WAL + Synchronisation Normal (read) | 483'558/s | 3us |
WAL + Synchronisation Normal (write) | 113'684/s | 8us |
WAL + In-memory (read) | 50'000'000/s | 0us |
WAL + In-memory (write) | 981'836/s | 1us |
WAL + Index (read) | 440'917/s | 3us |
WAL + Index (write) | 47'359/s | 25us |
WAL + Index (mixed, 80% read) | 197'012/s | 15us |
WAL + Index (mixed 80% write) | 59'111/s | 16us |
Scenario | Throughput | 90 percentile duration |
---|---|---|
Vanilla (read) | 94'786/s | 12us |
Vanilla (write) | 560/s | 2'463us |
WAL Mode (read) | 238'322/s | 6us |
WAL Mode (write) | 3'316/s | 585us |
WAL + Synchronisation Normal (read) | 275'406/s | 5us |
WAL + Synchronisation Normal (write) | 46'512/s | 18us |
WAL + In-memory (read) | 943'396/s | 1us |
WAL + In-memory (write) | 299'976/s | 4us |
WAL + Index (read) | 186'358/s | 7us |
WAL + Index (write) | 19'479/s | 46us |
WAL + Index (mixed, 80% read) | 75'153/s | 24us |
WAL + Index (mixed 80% write) | 22'801/s | 42us |
Scenario | Throughput | 90 percentile duration |
---|---|---|
Vanilla (read) | 73'800/s | 14us |
Vanilla (write) | 925/s | 1'484us |
WAL Mode (read) | 332'225/s | 4us |
WAL Mode (write) | 5'542/s | 211us |
WAL + Synchronisation Normal (read) | 315'756/s | 4us |
WAL + Synchronisation Normal (write) | 80'145/s | 12us |
WAL + In-memory (read) | 952'380/s | 1us |
WAL + In-memory (write) | 322'695/s | 3us |
WAL + Index (read) | 315'357/s | 4us |
WAL + Index (write) | 26'469/s | 34us |
WAL + Index (mixed, 80% read) | 121'703/s | 15us |
WAL + Index (mixed 80% write) | 36'260/s | 26us |
Observations
Notice the increase of queries per second across the board once write-ahead log (WAL) mode is
turned on and Synchronization is set to Normal. The 90th percentile duration column is
indicated in microseconds! Even the slow queries are still incredibly fast. The most
"real-world" scenario here is probably WAL + Index (mixed, 80% read)
. We are looking at
a QPS in the ballpark of 100'000
regardless of platform. Plenty fast for my usecases.
Limitations
I found it hard to get any speedup when trying to access a single SQLite DB from multiple
threads. Writes are single threaded by nature, not much of a debate there, but reads (at least
in WAL mode) should scale well. It's probably down to the library I use for connection
pooling. But considering single threaded performance is already so good, I didn't spend to
much time here. In Rust lingo, I just use an Arc<Mutex<Connection>>
to protect a
single connection from concurrent access.