Testing Crystal SQLite3 PRAGMAs usage and some HTTP benchmarks 👨🚀🚀
February 11, 2023 -Wanted to test the PRAGMA changes to crystal-sqlite3
and
perform some simple HTTP benchmarks, just for fun 😊
This uses the work shown on previous TIL, and the code from [PR #85][pr-85].
▶️ Similar to previous TIL, I made a recording of this test (and the errors) 💣 here.
1. A simple HTTP app that uses SQLite3
Started setting up a simple repository and define the dependencies that I
needed in shard.yml
:
+dependencies:
+ db:
+ github: crystal-lang/crystal-db
+ version: "~> 0.11.0"
+ sqlite3:
+ github: crystal-lang/crystal-sqlite3
+ version: "~> 0.19.0"
Installed using shards install
and proceed to write the application code:
require "http/server"
require "sqlite3"
class RandomContactApp
include HTTP::Handler
getter db : DB::Database
def initialize(@db)
end
def call(context)
rand_id = rand(1..500)
name = fetch_name(rand_id)
context.response.content_type = "text/plain"
context.response.print "Hello #{name}!"
end
private def fetch_name(id)
db.scalar("SELECT name FROM contacts WHERE id = ? LIMIT 1;", id).as(String)
end
end
A simple HTTP::Handler
for my app. This is the
most basic HTTP service you can create without the need of a web framework
(you know, less is more 😉).
This app simply picks a random number (to be used for the id
column) and
then look up in the database for the name
column of that record, returning
it in the response as simple text/plain
.
Next, we start the application and make sure we can stop the server when
pressing Ctrl+C
😅:
app = RandomContactApp.new(db)
logger = HTTP::LogHandler.new
server = HTTP::Server.new([logger, app] of HTTP::Handler)
Signal::INT.trap do
puts "Shutting down."
server.close
end
server.bind_tcp "0.0.0.0", 8080
puts "Listening on:"
server.addresses.each do |addr|
puts "- #{addr}"
end
puts "Use Ctrl-C to stop"
server.listen
puts "Shutdown completed."
2. The UX improvements for managing the connection
In order to pass a DB connection to my app, I would used something like the following:
db = DB.open("sqlite3://./contacts.db")
db.setup_connection do |conn|
# 1. Avoid writers to block readers by using WAL mode
# Ref. https://sqlite.org/pragma.html#pragma_journal_mode
conn.exec "PRAGMA journal_mode = WAL;"
# 2. Use normal synchronization to speed up operations (good combo with WAL)
# Ref. https://sqlite.org/pragma.html#pragma_synchronous
conn.exec "PRAGMA synchronous = NORMAL;"
# 3. Increases cache size available (from 2MB to 16MB in KB)
# Ref. https://sqlite.org/pragma.html#pragma_cache_size
conn.exec "PRAGMA cache_size = -16000;"
# 4. Allow waiting on periodic write locks from replication (or other process)
# Refs.
# - https://www.sqlite.org/pragma.html#pragma_busy_timeout
# - https://litestream.io/tips/#busy-timeout
conn.exec "PRAGMA busy_timeout = 5000;"
end
But thanks to the work done in PR #85, now I can manage all that in a single line:
db = DB.open("sqlite3://./contacts.db?journal_mode=wal&synchronous=normal&cache_size=-16000&busy_timeout=5000")
And remove the entire db.setup_connection
block. 😄
Since the changes hasn't been merged yet, we need to point to my fork,
so let's update shard.yml
one more time:
- sqlite3:
- github: crystal-lang/crystal-sqlite3
- version: "~> 0.19.0"
+ sqlite3:
+ github: luislavena/crystal-sqlite3
+ branch: accept-pragmas-for-connection-uri-query
And let's make sure the dependency gets updated:
$ shards install
Resolving dependencies
Fetching https://github.com/crystal-lang/crystal-db.git
Fetching https://github.com/luislavena/crystal-sqlite3.git
Using db (0.11.0)
Using sqlite3 (0.19.0 at caebc0a)
3. Using some dummy data
For this example decided to populate 500 entries of fake contacts, using a simple script:
require "sqlite3"
CREATE_SQL = <<-SQL
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
age INTEGER NOT NULL
);
SQL
INSERT_SQL = <<-SQL
INSERT INTO contacts (id, name, age) VALUES (?, ?, ?);
SQL
DB.open("sqlite3://./contacts.db") do |db|
db.exec CREATE_SQL
db.transaction do
500.times do |t|
id = t + 1
db.exec INSERT_SQL, id, "Name #{id}", id + 20
end
end
end
Named it generate
and run it once to populate our contacts.db
file.
4. Compiling our application
Since I mostly use containers to run my applications, decided to compile a static binary of my server so I could run that on the host OS (PopOS) instead of my container (Alpine Linux).
Used for this my hydrofoil-crystal container image that includes all the necessary dependencies (Eg. SQLite3) to produce these static binaries.
Added to my shard.yml
the targets, but you could use crystal build
directly:
authors:
- Luis Lavena <[email protected]>
+targets:
+ generate:
+ main: src/generate.cr
+ server:
+ main: src/server.cr
dependencies:
And build the server in release (optimized) mode:
$ shards build server --release --static
Dependencies are satisfied
Building: server
$ file bin/server
bin/server: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped
5. Simple benchmarks
Before we benchmark, let's remove the logger as sending stuff to STDIO has an impact on performance too (remember: gotta go fast!⚡)
app = RandomContactApp.new(db)
-logger = HTTP::LogHandler.new
-server = HTTP::Server.new([logger, app] of HTTP::Handler)
+server = HTTP::Server.new([app] of HTTP::Handler)
⚠️ Please note that the following benchmarks are synthetic and by no means they would represent real workloads or real world scenarios, where network overhead, latency, TLS negotiation and other factors would have an impact on the throughput (RPS) of your application.
Now that you've been warned, let's look at these numbers using plow
:
$ plow -c 1 -d 10s http://localhost:8080
Benchmarking http://localhost:8080 for 10s using 1 connection(s).
@ Real-time charts is listening on http://[::]:18888
Summary:
Elapsed 10s
Count 146032
2xx 146032
RPS 14602.535
Reads 1.445MB/s
Writes 0.808MB/s
Statistics Min Mean StdDev Max
Latency 48µs 66µs 15µs 1.011ms
RPS 14178.55 14602.01 398.16 15328.13
Latency Percentile:
P50 P75 P90 P95 P99 P99.9 P99.99
62µs 71µs 77µs 85µs 142µs 191µs 333µs
Latency Histogram:
63µs 117952 80.77%
72µs 20344 13.93%
87µs 5580 3.82%
128µs 1594 1.09%
165µs 414 0.28%
191µs 125 0.09%
293µs 20 0.01%
420µs 3 0.00%
14K RPS for a single-threaded server that randomly reads records from a DB, not bad.
But what about 5 or 10 concurrent connections:
$ plow -c 5 -d 10s http://localhost:8080
Benchmarking http://localhost:8080 for 10s using 5 connection(s).
@ Real-time charts is listening on http://[::]:18888
Summary:
Elapsed 10s
Count 210278
2xx 210278
RPS 21027.704
Reads 2.081MB/s
Writes 1.163MB/s
Statistics Min Mean StdDev Max
Latency 49µs 235µs 28µs 1.513ms
RPS 20424.45 21024.54 402.87 21766.7
Latency Percentile:
P50 P75 P90 P95 P99 P99.9 P99.99
230µs 248µs 261µs 269µs 372µs 426µs 540µs
$ plow -c 10 -d 10s http://localhost:8080
Benchmarking http://localhost:8080 for 10s using 10 connection(s).
@ Real-time charts is listening on http://[::]:18888
Summary:
Elapsed 10s
Count 211867
2xx 211867
RPS 21186.662
Reads 2.097MB/s
Writes 1.172MB/s
Statistics Min Mean StdDev Max
Latency 49µs 470µs 56µs 4.638ms
RPS 20653.64 21184.31 485.09 21902.19
Latency Percentile:
P50 P75 P90 P95 P99 P99.9 P99.99
470µs 496µs 516µs 535µs 657µs 1.032ms 1.466ms
A nice increase from 1 connection to 5, but not a great change from 5 to 10 concurrent connections.
Remember: this server was compiled using Crystal single-thread model, so it might not be taking advantage of all available CPUs. 🤔
6. Danger zone: multi-thread compilation 💣
Crystal introduced multi-thread support in 2019,
but only in preview mode, which requires you use a compilation option to
enable it: -Dpreview_mt
Decided to compile the server with that option and testing with 1 concurrent connection worked, but other concurrency options just made it crash:
$ CRYSTAL_WORKER=1 bin/server
Listening on:
- 0.0.0.0:8080
Use Ctrl-C to stop
Invalid memory access (signal 11) at address 0x7fc495324958
[0x4766b0] ???
[0x476565] ???
[0x8855ca] ???
I will investigate further testing crystal-sqlite3
in
multi-thread mode and report that back to the project. 🕵️
Have a nice weekend!