surrealdb/crud-bench
surrealdb/crud-bench has 60 GitHub stars and 11 forks. Star changes are not measured yet. Last checked 2026-11-14 02:22 UTC. Last push 2026-11-13.
What you can do
- TiKV Integration: Designed specifically for benchmarking SurrealDB with TiKV distributed storage
- Multi-Instance Support: Connect to multiple SurrealDB instances using a single endpoint configuration
- Round-Robin Load Balancing: Automatically distributes client connections evenly across all configured instances
- Networked Connections Only: Designed for remote SurrealDB instances (supports ws://, wss://, http://, https://)
Measured star growth
- in 24h
- Collecting history
- in 7 days
- Collecting history
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
About
A benchmarking tool for testing and comparing the performance of both embedded and networked SQL and NoSQL databases.
DataREADME.md
Original
crud-bench
The crud-bench benchmarking tool is an open-source benchmarking tool for testing and comparing the performance of a number of different workloads on embedded, networked, and remote databases. It can be used to compare both SQL and NoSQL platforms including key-value, embedded, relational, document, and multi-model databases. Importantly crud-bench focuses on testing additional features which are not present in other benchmarking tools, but which are available in SurrealDB.
The primary purpose of crud-bench is to continually test and monitor the performance of features and functionality built in to SurrealDB, enabling developers working on features in SurrealDB to assess the impact of their changes on database queries and performance.
The crud-bench benchmarking tool is being actively developed with new features and functionality being added regularly.
Contributing
The crud-bench benchmarking tool is open-source, and we encourage additions, modifications, and improvements to the benchmark runtime, and the datastore implementations.
How does it work?
When running simple, automated tests, the crud-bench benchmarking tool will automatically start a Docker container for the datastore or database which is being benchmarked (when the datastore or database is networked). This configuration can be modified so that an optimised, remote environment can be connected to, instead of running a Docker container locally. This allows for running crud-bench against remote datastores, and distributed datastores on a local network or remotely in the cloud.
In one table, the benchmark will operate 5 main tasks:
- Create: inserting N unique records, with the specified concurrency.
- Read: read N unique records, with the specified concurrency.
- Update: update N unique records, with the specified concurrency.
- Scans: perform a number of range and table scans, with the specified concurrency.
- Delete: delete N unique records, with the specified concurrency.
With crud-bench almost all aspects of the benchmark engine are configurable:
- The number of rows or records (samples).
- The number of concurrent clients or connections.
- The number of concurrent threads (concurrent messages per client).
- Whether rows or records are modified sequentially or randomly.
- The primary id or key type for the records.
- The row or record content including support for nested objects and arrays.
- The scan specifications for range or table queries.
Benchmarks
As crud-bench is in active development, some benchmarking workloads are already implemented, while others will be implemented in future releases. The list below details which benchmarks are implemented for the supporting datastores and lists those which are planned in the future.
CRUD
- Creating single records in individual transactions
- Reading single records in individual transactions
- Updating single records in individual transactions
- Deleting single records in individual transactions
- Batch creating multiple records in a transaction
- Batch reading multiple records in a transactions
- Batch updating multiple records in a transactions
- Batch deleting multiple records in a transactions
Scans
- Full table scans, projecting all fields
- Full table scans, projecting id field
- Full table count queries
- Scans with a limit, projecting all fields
- Scans with a limit, projecting id field
- Scans with a limit, counting results
- Scans with a limit and offset, projecting all fields
- Scans with a limit and offset, projecting id field
- Scans with a limit and offset, counting results
Filters
- Full table query, using filter condition, projecting all fields
- Full table query, using filter condition, projecting id field
- Full table query, using filter condition, counting rows
Indexes
- Indexed table query, using filter condition, projecting all fields
- Indexed table query, using filter condition, projecting id field
- Indexed table query, using filter condition, counting rows
- Full-text search query, using single search term, projecting all fields
- Full-text search query, using single search term, projecting id field
- Full-text search query, using single search term, counting rows
- Full-text search query, using boolean AND search terms, projecting all fields
- Full-text search query, using boolean AND search terms, projecting id field
- Full-text search query, using boolean AND search terms, counting rows
- Full-text search query, using boolean OR search terms, projecting all fields
- Full-text search query, using boolean OR search terms, projecting id field
- Full-text search query, using boolean OR search terms, counting rows
Vector search
- Bruteforce (exact) KNN query
- HNSW KNN query, with configurable
m/ef_construction/ef_search - DiskANN KNN query, with configurable
degree/l_build/alpha/l_search - Vector index build timing, until the index serves at index speed
- Recall@k against exact ground truth, scored identically for every engine
- Parameter sweeps tracing the recall/latency curve over a single index build
- Reproducible corpora, so engines and runs are compared on identical data
- Filtered KNN at several selectivities, with recall scored per predicate
- Index size on disk and resident memory
Relationships
- Fetching or traversing 1-level, one-to-one relationships or joins
- Fetching or traversing 1-level, one-to-many relationships or joins
- Fetching or traversing 1-level, many-to-many relationships or joins
- Fetching or traversing n-level, one-to-one relationships or joins
- Fetching or traversing n-level, one-to-many relationships or joins
- Fetching or traversing n-level, many-to-many relationships or joins
Workloads
- Workload support for creating, updating, and reading records concurrently
Requirements
- Docker - required when running automated tests
- Rust - required when building crud-bench from source
- Cargo - required when building crud-bench from source
Usage
cargo run -r -- -h
Usage: crud-bench [OPTIONS] --database <DATABASE> --samples <SAMPLES>
Options:
-n, --name <NAME> An optional name for the test, used as a suffix for the JSON result file name
-d, --database <DATABASE> The database to benchmark [possible values: dry, map, arangodb, dragonfly, fjall, keydb, mdbx, lmdb, mariadb, mongodb, mysql, neo4j, postgres, redb, redis, rocksdb, scylladb, slatedb, sqlite, surrealdb, surrealdb2, surrealdb3, surrealkv, surrealmx, surrealds]
-i, --image <IMAGE> Specify a custom Docker image
-p, --privileged Whether to run Docker in privileged mode
-e, --endpoint <ENDPOINT> Specify a custom endpoint to connect to
-b, --blocking <BLOCKING> Maximum number of blocking threads (default is the number of CPU cores) [default: 12]
-w, --workers <WORKERS> Number of async runtime workers (default is the number of CPU cores) [default: 12]
-c, --clients <CLIENTS> Number of concurrent clients [default: 1]
-t, --threads <THREADS> Number of concurrent threads per client [default: 1]
-s, --samples <SAMPLES> Number of samples to be created, read, updated, and deleted
-r, --random Generate the keys in a pseudo-randomized order
--sync Whether to ensure data is synced and durable
--measurement-time <SECONDS> Seconds each timed scan leg runs for when the scan sets no `iterations` [default: 30]
--warmup-time <SECONDS> Seconds of untimed warm-up before each timed scan leg (0 disables) [default: 5]
--min-iterations <N> Fewest iterations a timed window may record [default: 10]
--max-iterations <N> Most iterations a timed window may record [default: 10000000]
--mode <MODE> Which phases to run: `all`, `load` (keep the dataset), or `query` (reuse it) [default: all]
--data-dir <DATA_DIR> Directory recording what a `load` run left behind [env: CRUD_BENCH_DATA_DIR=] [default: .crud-bench-data]
--operation-timeout <OPERATION_TIMEOUT> Per-operation timeout in seconds [env: CRUD_BENCH_OPERATION_TIMEOUT=] [default: 1800]
--persisted Whether to enable disk persistence for Redis-family databases
--optimised Use optimised database configurations instead of defaults
--color <COLOR> When to use colour in terminal output (`NO_COLOR` disables colour for `auto` and `always`) [default: auto] [possible values: auto, always, never]
-k, --key <KEY> The type of the key [default: integer] [possible values: integer, string26, string90, string250, string506, uuid]
--show-sample Print-out an example of a generated value
--pid <PID> Sample CPU, memory and disk of this process and its children instead of crud-bench itself. Use it to measure a database server that crud-bench did not start
--store-results Store benchmark results in SurrealDB
--storage-endpoint <STORAGE_ENDPOINT> SurrealDB endpoint for storing results [env: CRUD_BENCH_STORAGE_ENDPOINT=] [default: ws://localhost:8000]
--config <CONFIG> Path to the benchmark TOML (`[[scans]]`, `[[batches]]`, `[value]`) [env: CRUD_BENCH_CONFIG=] [default: config/bench.toml]
--skip-scans Skip all scan benchmarks
--skip-batches Skip all batch benchmarks
--skip-indexes Skip index operations, but still table scan queries
--emit-phase-markers Emit line-oriented phase markers (`… starting`, `Benchmark starting`) for log-based tooling (e.g. `dev.sh` perf windows). Off by default; also on when `CRUD_BENCH_EMIT_PHASE_MARKERS` is `1`, `true`, `yes`, or `on`
--corpus-seed <CORPUS_SEED> Seed for generated row content, overriding `seed` in the benchmark TOML. With a seed the corpus is a pure function of `(seed, sample)`, making the run reproducible and letting vector-search ground truth reconstruct the corpus instead of reading it back
--ground-truth-cache <DIR> Directory holding cached vector-search ground truth [env: CRUD_BENCH_GROUND_TRUTH_CACHE=] [default: .crud-bench-gt]
--vector-warmup-seconds <SECONDS> Seconds a vector index may be warmed before a timed leg. Warming stops on its own once latency plateaus; this is a safety cap. Raise it for large corpora [default: 30]
-h, --help Print help (see more with '--help')
For more detailed help information run the following command:
cargo run -r -- --help
Resource columns
The CPU, Memory, Reads and Writes columns show the resources that one process used during each test. crud-bench samples that process every 250 ms. Memory is the peak resident memory (see below). CPU is a percentage of all the CPUs on the host.
crud-bench chooses the process as follows:
| Database | Process that crud-bench samples |
|---|---|
| Started by crud-bench in Docker | The main process of the container, and its children |
Given with --pid <PID> |
That process, and its children |
Embedded (for example rocksdb, -e memory) |
crud-bench itself, which also runs the database |
Networked at --endpoint, without --pid |
crud-bench itself. The columns do not show the server |
When a process has children, crud-bench adds their values together. For example, Postgres serves each connection from a child process.
Memory means the following:
| Process that crud-bench samples | Memory column |
|---|---|
| One process | Resident memory (RSS) of the process |
| A process and its children | Proportional memory (PSS) added over the processes, from /proc/<pid>/smaps_rollup |
The resident memory of each process includes every shared page that the process maps. For Postgres, every backend maps the same shared buffers, so the sum of resident memory counts those buffers once for each connection, and can be larger than the memory of the host. PSS divides each shared page among the processes that map it, so the sum counts each page once. For one process, PSS and RSS are almost the same, and crud-bench keeps RSS.
Linux shows smaps_rollup only to the owner of the process or to root. When crud-bench cannot read it, it adds the
private memory (RssAnon in /proc/<pid>/status) of each process, and the largest shared and file-backed memory
(RssShmem and RssFile) of any one process. That value can be lower than the real memory of the server, because
two processes can map different shared pages. crud-bench shows a warning when it uses this value. Run crud-bench as
root (run.sh --elevated) to get PSS.
crud-bench shows the sampled process before the benchmark starts and in the header of the results. The result*.json
file records it in metadata.resources:
"resources": { "pid": 12345, "name": "surreal", "source": "pid" }
The source value is docker, pid or client.
Disk counters come from /proc/<pid>/io. Linux shows these counters only to the owner of the process or to root.
When crud-bench cannot read them, for example for a Docker container that runs as root, the Reads and Writes columns
show zero. Run crud-bench as root (run.sh --elevated) to read them.
To measure a server that you started yourself, give its process ID:
cargo run -r -- -d surrealdb -e ws://127.0.0.1:8000 --pid "$(pgrep -x surreal)" -s 100000
run.sh does this for you. When you give --endpoint with a port, it finds the process that listens on that port.
You can also give the process with --pid <PID> or the PID environment variable.
Comparing result*.json files in the browser
Open compare/index.html locally (drag and drop benchmark JSON artefacts). Rows and labels match CLI/CSV ordering from src/result.rs. Nothing is uploaded; ApexCharts loads from jsDelivr (works offline only if that script is cached or vendored beside the HTML file).
Workload shape (document template, scan cases, and batch throughput tests) is defined in a single TOML file. Use
--config <PATH> or the environment variable CRUD_BENCH_CONFIG (default: config/bench.toml).
Value
The [value] table in the benchmark TOML customizes the row, document, or record value used in the benchmark. It uses
the same JSON-oriented template rules as before, expressed as TOML keys and nested tables.
[!NOTE] For tabular, or column-oriented databases (e.g. Postgres, MySQL, ScyllaDB), the first-level fields of the JSON structure are translated as columns, and any nested structures will be stored in a JSON column where possible.
Within the JSON structure, the following values are replaced by randomly generated data:
- Every occurrence of
string:Xwill be replaced by a random string withXcharacters. - Every occurrence of
text:Xwill be replaced by a random string made of words of 2 to 10 characters, for a total ofXcharacters. - Every occurrence of
string:X..Ywill be replaced by a random string betweenXandYcharacters. - Every occurrence of
text:X..Ywill be replaced by a random string made of words of 2 to 10 characters, for a total betweenXandYcharacters. - Every
intwill be replaced by a random integer (i32). - Every
int:X..Ywill be replaced by a random integer (i32) betweenXandY. - Every
floatwill be replaced by a random float (f32). - Every
float:X..Ywill be replaced by a random float (f32) betweenXandY. - Every
uuidwill be replaced by a random UUID (v4). - Every
boolwill be replaced by atrueorfalse. - Every
string_enum:A,B,Cwill be replaced by a string fromABorC. - Every
int_enum:A,B,Cwill be replaced by a i32 fromABorC. - Every
float_enum:A,B,Cwill be replaced by a f32 fromABorC. - Every
datetimewill be replaced by a datetime (ISO 8601). - Every
vector:Xwill be replaced by anX-dimension vector with uniform components in[-1, 1]. - Every
vector:X:LO..HIwill be replaced by anX-dimension vector with uniform components in[LO, HI]. - Every
vector:X:clustered:Nwill be replaced by anX-dimension unit vector drawn from a mixture ofNclusters. Add:SIGMA(default0.35) to widen or tighten the clusters.
{
"text": "text:30",
"text_range": "text:10..50",
"bool": "bool",
"string_enum": "enum:foo,bar",
"datetime": "datetime",
"float": "float",
"float_range": "float:1..10",
"float_enum": "float:1.1,2.2,3.3",
"integer": "int",
"integer_range": "int:1..5",
"integer_enum": "int:1,2,3",
"uuid": "uuid",
"nested": {
"text": "text:100",
"array": [
"string:10",
"string:2..5"
]
}
}
Scans
Scan workloads are defined by [[scans]] entries in the benchmark TOML (see config/bench.toml). Each table describes
one logical benchmark (or multiple [[scans.runs]] variants). The JSON examples below remain valid documentation for the
fields on each scan object.
[!NOTE] Not every database benchmark adapter supports scans or range queries. In such cases, the benchmark will not fail but the associated tests will indicate that the benchmark was
skipped.
Unknown keys are rejected. A misplaced or misspelled key used to be dropped in silence, so a run would report a healthy-looking number for a configuration nobody had written — the same failure mode the vector-search measurement work exists to eliminate. Parsing now fails and names the offending key and the table it appeared in.
Each scan object can make use of the following values:
id(required): Stable identifier for grouping in the CLI, result tables, CSV output, and for index create/drop whenwith_indexis present and not skipped. Use a simple identifier (e.g.where_field_integer_eq); human-readable titles live inname/ runnameand may contain characters that are not valid in SurrealDB or Neo4j index names.name: A descriptive name for the test (use this orruns, not both).runs: An array of{ "name", "iterations"?, "projection"?, "vector_query"? }objects that share the same scan parameters (condition,with_index, and so on). Each run becomes a separate benchmark with its own display name. Per-runiterations,projectionandvector_queryoverride the entry-level value when both are set; if a run omits one, the entry-level value applies (projectiondefaulting to full-record scans like a single-object entry withoutprojection). A per-runiterationsmatters when one array mixes leg kinds — a bruteforce KNN leg is a full table scan costing seconds per query at 100k rows, while the graph-index legs beside it answer in under a millisecond, and no single count suits both.iterations: A fixed number of timed iterations. When omitted, the scan is measured over a time window instead, so its run time does not grow with the dataset size.measurement_time/warmup_time: Seconds of the measured window and of the untimed warm-up before it, overriding--measurement-timeand--warmup-time. Ignored wheniterationsis set. A window never interrupts a query already running, and always records at least--min-iterationssamples, so a very slow query can run past it. The warm-up starts with--min-iterationsqueries in flight and admits more only as queries complete, and the measured window then starts no more queries than the warm-up's throughput says fit in it (with 50% headroom, within--min-iterationsand--max-iterations). So a leg of slow queries, such as a full-table scan over millions of rows, runs the few that fit its window instead of one per client thread, while a leg of fast queries still runs every thread for the whole window. With--warmup-time 0there is no warm-up to size the window from, and every thread starts a query when it opens. Result rows for a windowed scan are labelled with the window (for example30s) instead of an iteration count.projection: The projection type of the scan:"ID": only the ID is returned."FULL": the whole record is returned."COUNT": count the number of records.
start: Skips the specified number of rows before starting to return rows.limit: Specifies the maximum number of rows to return.expect: (optional) Asserts the expected number of rows returned.clients/threads: (optional) Concurrency for this scan's timed legs, overriding--clientsand--threads.clientsis capped at the pool--clientscreated. Index DDL is unaffected — it always runs on a single client.
[
{
"id": "paging_limit_100",
"name": "limit100",
"projection": "FULL",
"start": 0,
"limit": 100,
"expect": 100
},
{
"id": "paging_start_100",
"name": "start100",
"projection": "ID",
"start": 100,
"limit": 100,
"expect": 100
}
]
Multiple benchmarks that share the same filter, index, and write settings can use runs instead of duplicating the object:
[
{
"id": "idx_x_eq_1",
"runs": [
{ "name": "select(*) where(x = 1)", "projection": "FULL" },
{ "name": "count(*) where(x = 1)", "projection": "COUNT" }
],
"iterations": 10000,
"condition": { "sql": "x = 1", "mysql": "x = 1" },
"with_index": { "fields": ["x"] }
}
]
Index build time
An [I]ndex · … · build row is timed from the build call until the index serves at index speed,
which for some engines is long after the build call returns. The two moments coincide for an engine
whose build call does all the work, and can be minutes to hours apart for one that finishes the index
in the background; only the later one means the same thing for both. At 100k rows × 768-d, HNSW
M 16 / EFC 200 — one run per engine on a shared machine, so the absolute times are only
indicative; the split is the point:
| engine | build call returned | index queryable | why |
|---|---|---|---|
| pgvector | 2 m 19 s | 2 m 19 s | CREATE INDEX is synchronous |
| Redis | 4.5 ms | 12.1 s | FT.CREATE accepts the schema; documents are indexed afterwards |
| SurrealDB | 12.9 s | 2 m 50 s | ready follows row enumeration; the graph is built afterwards |
Timing the build call alone reported the first column. At 1M rows that made SurrealDB look ~28× faster to build than pgvector — 56 s against 25 m 29 s — while its index was still being built an hour later. So each engine's wait for its index to become queryable — pending entries drained, materialisation finished, background indexing complete — is part of the timed build, and the CPU and memory sampled for the build row cover it too.
The build call's own time is kept as a diagnostic, build_returned in the JSON and Build_returned
in the CSV. It is deliberately not in the summary table: it is the figure that does not mean the
same thing across engines. Result files written before this change lack index_build_timing in
their metadata, and the comparison viewer flags a mix of old and new files rather than setting one
definition's number beside the other's.
A search is only ever timed against an index that has finished. For SurrealDB that means waiting
until INFO FOR INDEX reports ready, no pending entries, and compacting: false — ready
alone arrives while a vector index is still being built from per-record pending entries, and a kNN
query in that state scores the remainder by hand. A server that does not report compacting cannot
say when that has finished, so crud-bench skips HNSW and DiskANN legs there, reporting -,
rather than time a scan wearing the index's name. SurrealDB 3.3.0 is the first release that reports
it, so both the crate embedded mode links (-e memory, -e surrealkv:…) and the
nightly Docker image server mode uses by default are covered; releases before 3.3.0 defer the same
work without reporting it, and get the skip.
The wait is bounded by --operation-timeout, like every timed operation. A build that used to fit
in the 30-minute default can stop fitting once its materialisation counts — a large vector index is
the usual case — and the error then says so; raise the timeout to allow for it. Readiness is polled
at a tenth of the time waited so far, between 10 ms and 250 ms, so a reported build is at most
max(10 ms, min(10%, 250 ms)) late: a sub-second build is not rounded up to a whole poll interval.
Vector search
Vector workloads live in config/vector.toml. Each [scans.runs.vector_query]
block describes one KNN benchmark:
field(required): thevector:<dim>column to search. The index, when the strategy needs one, is derived from this — do not also declarewith_index.top_k(required): number of neighbours to return.distance:cosine(default),euclidean,inner_product, ormanhattan.index_strategy(required):{ kind = "bruteforce" },{ kind = "hnsw", m, ef_construction, ef_search }, or{ kind = "diskann", degree, l_build, alpha, l_search }. All knobs are required — results without explicit parameters cannot be interpreted. The search-time knob (ef_search,l_search) also accepts a list, which is swept: see below.holdout:{ count, seed }for the query set. Query vectors are generated fromseedusing the schema's own vector generator and are never inserted, so no query is its own nearest neighbour.filters: a list of predicates the KNN result is restricted to, one timed leg each plus an unfiltered baseline — see Filtered KNN.filter_index:trueto index each filter column before the legs (untimed) and drop it after — see Indexed filter columns. Defaultfalse.tie_epsilon: relative tolerance when deciding whether a returned neighbour counts as correct (default0.0, i.e. strict recall@k). Engines compute distances at different precisions, so rows straddling the k-th boundary can swap without any real quality difference; a small tolerance stops that reading as a recall gap.
Engine support
| engine | bruteforce | HNSW | DiskANN | filtered | notes |
|---|---|---|---|---|---|
| SurrealDB (3.x) | ✓ | ✓ | ✓ | ✓ (HNSW + DiskANN) | <|k,ef|> operator; DiskANN needs a build that has the DDL; HNSW and DiskANN legs need a server that reports building.compacting (see Index build time) |
| SurrealDB (2.x) | ✓ | ✓ | — | — | 2.6 has no DiskANN; filtered legs are declined, not answered unfiltered |
| PostgreSQL | ✓ | ✓ | — | ✓ | pgvector; DiskANN would need pgvectorscale |
| Redis Stack | ✓ (FLAT) | ✓ | — | ✓ | no native L1/Manhattan metric |
| everything else | — | — | — | — | the run is skipped, not failed |
Engines without vector support skip these runs rather than failing, so a mixed run reports - for
them rather than aborting. A leg whose strategy needs an index it cannot build is skipped the same
way.
Vector data
Use vector:<dim>:clustered:<n> rather than vector:<dim> for anything whose recall you intend to
read. Uniform components leave a corpus with no neighbourhood structure — under distance
concentration every pair sits at roughly the same distance, so a query's true neighbours are
conspicuous and any index walks straight to them. Measured over 20k 128-d rows, the nearest neighbour
sits at 65% of a random pair's distance under vector:128, and at 8% under
vector:128:clustered:200. Recall only tells you anything on the second.
Clusters are drawn from the corpus seed, so two seeds give two genuinely different datasets rather than one structure populated differently. Query vectors come from the same generator, so they follow the corpus distribution by construction.
Corpus size matters more than dimension. Dimension scales cost linearly without making the search harder; rows add candidates that can confuse a graph. Measured against embedded SurrealDB at 128 dimensions, neither graph index beats a linear scan below ~100k rows — at 200k, HNSW is 1.7x faster than bruteforce and DiskANN 3.4x. Treat 100k as a floor, and 1M as the size worth quoting, which also matches the scale of the standard ANN datasets.
Parameter sweeps
ef_search and l_search accept a list as well as a single value:
index_strategy = { kind = "hnsw", m = 16, ef_construction = 200, ef_search = [16, 32, 64, 128, 256] }
Each value becomes its own timed leg, all sharing a single index build, and each is reported as a
separate row labelled with the value it used. A lone ef_search is one arbitrary point on a curve —
the comparison worth making is the curve itself, what recall an index reaches at a given latency
budget — and tracing it this way costs one index build rather than one per point.
Values must be at least top_k: a search budget narrower than k cannot return k neighbours.
Engines apply the budget differently and crud-bench hides the difference. SurrealDB carries it in the
KNN operator, Redis passes EF_RUNTIME on the query rather than fixing it at FT.CREATE, and
pgvector takes it from the hnsw.ef_search session GUC, which is applied to every client before each
leg — a setting made only on the client that built the index would reach one session out of
--clients.
Filtered KNN
Filtered workloads live in config/vector-filtered.toml. Where the
unfiltered config asks how fast and how accurately an index finds the nearest neighbours, this one
asks the question production asks: among the rows that match this predicate.
[scans.runs.vector_query]
field = "embedding"
top_k = 10
distance = "cosine"
index_strategy = { kind = "hnsw", m = 16, ef_construction = 200, ef_search = [16, 64, 128, 256] }
filters = [
{ name = "sel~1%", field = "number", op = "lte", value = 50 },
{ name = "sel~10%", field = "number", op = "lte", value = 500 },
{ name = "tag~33%", field = "status", op = "eq", value = "published" },
]
Each predicate becomes its own timed leg, and an unfiltered leg runs first under the same index build and the same warm index — "what does filtering cost?" is not answerable against a baseline measured somewhere else. Legs are the cross product of the search sweep and the filter list, all over a single build, since the index does not depend on the predicate.
Why this is worth measuring separately: engines answer a filtered query in structurally different ways, and the differences do not show up in latency.
| strategy | accuracy | latency | failure mode |
|---|---|---|---|
| pre-filter | exact | grows as the predicate widens | a wide predicate degenerates to a full scan |
| post-filter | drops as the predicate narrows | fast, and fastest when worst | a top-10 query at 1% selectivity can return nothing |
| filtered traversal | in between | in between | can stall when matching rows are scattered across the graph |
Post-filtering — search the index for k, then discard the hits that do not match — is the fastest
of the three and the least useful, because the k the index chose were chosen without knowing about
the predicate. A latency column rewards it. Recall is what exposes it, which is why every filtered
leg is scored against an answer key computed per predicate: exact top-k among the matching rows,
computed in the harness from the seeded corpus.
What this actually measures
Measured on 20k rows of 128-d clustered vectors, top_k = 10, HNSW m = 16 / efc = 200 / ef_search = 64, one client — all three engines answering the identical questions against the
identical answer key:
| engine | exact leg | HNSW unfiltered | HNSW @ 10% | HNSW @ 33% | latency @ 10% vs unfiltered |
|---|---|---|---|---|---|
| SurrealDB 3.x | 1.000 | 1.000 | 1.000 | 1.000 | 3.6 ms → 16.8 ms (4.6x) |
| Redis Stack | 1.000 | 0.996 | 1.000 | 1.000 | 0.50 ms → 0.54 ms (flat) |
| PostgreSQL (pgvector) | 1.000 | 1.000 | 0.656 (p5 0.30) | 0.998 | 0.83 ms → 0.70 ms (flat) |
Three structurally different answers to the same question, and the latency columns rank pgvector first. It is the only one of the three that loses a third of the true neighbours at 10% selectivity, and its worst 5% of queries lose 70% of theirs — while costing no more than the unfiltered query, because it never looked at the rows it skipped. SurrealDB pays 4.6x in latency and keeps every neighbour; Redis pays nothing measurable and keeps every neighbour.
That is the entire argument for scoring filtered legs against a filter-aware answer key rather than timing them. No amount of latency measurement distinguishes the first row from the third.
The divergence is not only between engines. DiskANN on the same SurrealDB build, same corpus, same
predicates, sweeping l_search:
l_search |
unfiltered | @ 10% | @ 33% |
|---|---|---|---|
| 50 | 1.000 (0.88 ms) | 0.512 (p5 0.20, 1.09 ms) | 1.000 (1.13 ms) |
| 200 | 1.000 (0.90 ms) | 1.000 (2.46 ms) | 1.000 (1.33 ms) |
| 400 | 1.000 (0.95 ms) | 1.000 (2.63 ms) | 1.000 (1.47 ms) |
A traversal budget that is ample unfiltered — l_search = 50 reaches exact at every budget with no
predicate — finds barely half the true neighbours once a 10%-selective predicate is applied, and
needs roughly 4x the budget to recover. Where SurrealDB's HNSW absorbed the same predicate by
traversing harder at a fixed ef_search (4.6x latency, recall intact), DiskANN keeps its latency and
loses recall instead. And at l_search = 50 the wrong answer is the faster one — 1.09 ms against
2.46 ms — so latency picks it again.
This is why the filter list expands against the search sweep rather than beside it: the search budget a filtered query needs depends on the selectivity, and any single point would have reported DiskANN as either broken or fine depending on an arbitrary choice.
It is also why the low end of each shipped ladder sits below saturation and should stay there. A
ladder whose every point already reaches exact prints a flat column of 1.000 and measures nothing —
the recall equivalent of reporting latency with no recall column. The starved point is what makes the
effect visible, and the unfiltered leg beside it at the same budget is what identifies it as a
budget/selectivity interaction rather than a defect.
The HNSW ladder, ef_search = [16, 64, 128, 256], is calibrated at the scale the config is meant
for (#290). Unfiltered recall@10 at 1M × 768-d:
ef_search |
16 | 64 | 128 | 256 |
|---|---|---|---|---|
| pgvector | 0.516 | 0.821 | 0.937 | 0.975 |
| Redis | 0.463 | 0.774 | 0.904 | 0.966 |
16 sits clearly under the knee, 64 and 128 across it, 256 near saturation. The ladder it replaced,
[32, 128], was chosen on a 20k × 128-d smoke run on the guess that it was already saturated; at 1M
it was not (32 reads 0.670 / 0.608). Budgets saturate at different points as a corpus grows, which is
why the calibration had to happen at the target scale. Selective predicates do not follow the budget
at all — at 1% pgvector's post-filter never passes 0.271 on this ladder, while Redis brute-forces the
matching rows at 1.000 — so the ladder is chosen on the unfiltered and 50% columns.
The DiskANN ladder, [50, 200], is not calibrated. DiskANN runs only on SurrealDB, whose 1M arm
is blocked: the index keeps materialising for over an hour after reporting ready, so there is nothing
yet to calibrate against.
Note every exact leg reads 1.000 under both predicates on all three engines. That is the check
that the three renderings select the same rows the harness does: if SurrealQL, ANSI SQL and the
RediSearch expression disagreed with the in-process predicate by even one row, exact search could not
score a perfect recall against it.
Predicate grammar
A filter is { name, field, op, value }:
name: labels the leg in the results. Must be unique within a scan; it is not part of the ground-truth cache key, so renaming a predicate does not invalidate a computed answer key.field: aninteger,float,stringorboolcolumn from the value template. Other column types are rejected — a datetime or decimal filter would need per-engine literal formats and collation rules, and getting either subtly wrong leaves two engines answering different questions.op:eq,ne,lt,lte,gt,gte, orin. The ordering operators require a numeric column;intakes a list and is the natural way to dial selectivity on a categorical column.value: a literal, or a list forin. Text literals are restricted to letters, digits, space and_-./+:@— every engine here quotes strings differently, and a mis-escaped literal does not fail loudly, it silently selects a different set of rows in one engine than in another.
The predicate has to be something crud-bench can evaluate, not an opaque per-dialect string like
scan.condition. That is what makes a filter-aware answer key possible at all: the harness
reconstructs the seeded corpus, applies the same predicate, and computes exact top-k over the rows it
admits.
Selectivity is measured, not declared
The ground-truth sweep regenerates every row anyway, so it counts matches while it is there. The
share that actually matched is reported in the leg label and in the Filter column of the summary
table (Filter_selectivity in CSV, filter_selectivity in JSON). Names like sel~1% are intentions;
the reported figure is what the corpus did.
This also makes the sweep cheaper rather than dearer: a non-matching row is skipped before any distance is computed, so a 1%-selective predicate pays for about 1% of the arithmetic.
A predicate that matches no rows fails the run rather than reporting it. Every query would have an empty answer, recall would be undefined, and the timed leg would be measuring an engine returning nothing — a configuration mistake, not a result.
How each engine is asked
| engine | rendering |
|---|---|
| SurrealDB (3.x) | WHERE <field> <|k,ef|> $q AND <pred>, with the KNN operator leading — HNSW and DiskANN share this path; bruteforce gets a plain WHERE before the ORDER BY |
| PostgreSQL | SELECT id FROM record WHERE <pred> ORDER BY <field> <op> $1 LIMIT k |
| Redis Stack | (<pred>)=>[KNN k @v $q …], a hybrid query; filter columns are mirrored into the vec:{key} HASH and declared NUMERIC / TAG CASESENSITIVE in FT.CREATE |
CASESENSITIVE matters: RediSearch folds case on TAG fields by default, which would admit rows the
answer key excludes and produce a systematic recall error that looks like an index fault.
SurrealDB 2.x declines filtered legs and reports -. Running the unfiltered query and scoring it
against a filter-aware answer key would report a recall collapse that says nothing about the engine,
and a skip is distinguishable from that where a wrong number is not.
DiskANN is filtered wherever it exists, which today is SurrealDB 3.x alone — it shares the index path
above with HNSW. Neither pgvector nor Redis Stack ships a DiskANN index, so there is nowhere else to
apply it; pgvectorscale's diskann would be a separate adapter and is tracked in #284.
Indexed filter columns
Whether the filter column is indexed changes how an engine can answer, far more than any search
parameter does. Without an index it has to test the predicate candidate by candidate as it
searches; with one it can start from the rows that match. That is a property of the schema, not the
engine, so it is a switch — filter_index = true on a vector_query — and
config/vector-filtered.toml runs every strategy both ways, the second labelled · filter index.
Each distinct filter column is indexed after the vector index and before the first leg, so the unfiltered baseline runs against the same schema, and dropped after the last. The build is not timed: it is schema setup, not the index under test.
| engine | what filter_index does |
|---|---|
| SurrealDB (3.x) | DEFINE FIELD <col> ON record TYPE <int|float|string|bool> plus a b-tree index; both removed afterwards |
| PostgreSQL | a b-tree index, then ANALYZE so the planner can cost it straight after a bulk load |
| Redis Stack | nothing: filter columns are already NUMERIC / TAG fields of the vector index, so the two variants measure the same configuration |
| SurrealDB (2.x) | declined (-), like every filtered leg |
SurrealDB needs the declared type. Its KNN pre-filter turns an indexed predicate into an allow-list of matching records — scored exactly when few match, without touching the graph — but only trusts a b-tree index on a column that cannot hold arrays: on a schemaless table an array value fans out to one index entry per element, so the index could admit rows the predicate rejects. With an index and no declared type the plan does not change. At 1% selectivity on 50k × 768-d, the pre-filter took a filtered HNSW query from 1.1 s and recall 0.96 to 25 ms and 1.000 (#314).
--skip-indexes removes filter_index scans along with the other index builds.
Two caveats worth knowing when reading the numbers:
- pgvector runs with its defaults. Without
filter_indexit post-filters its HNSW scan, its documented default (hnsw.iterative_scan = off). Iterative scans are not exercised yet (#312). - Redis mirrors filter columns on every write. They ride in the same
HSETas the embedding, so the cost is marginal, but the create and update phases of a filtered config are not byte-identical in work to those ofconfig/vector.toml.
cargo run -r -- -d surrealdb -s 100000 -c 12 -t 24 --config config/vector-filtered.toml
Concurrency
Vector legs default to clients = 1, threads = 1 in config/vector.toml, and that default is
load-bearing. Once an index is warm a KNN query answers in well under a millisecond, so at the
benchmark's usual concurrency the timed legs measure queueing rather than search: the same query
measured 0.4 ms at one client and roughly 1500 ms at sixty-four. Raise the setting to measure
throughput under load — that is a legitimate thing to want — but do not read the latency columns of
such a run as search cost.
The first leg timed after an index build is also warmed with untimed queries first. Without that it absorbs the whole cost of warming the index, and not by a little: on a 50k-row HNSW the first leg measured ~295 ms per query against ~0.5 ms for the identical query in the leg that followed, and it was more accurate as well — a cold index answers differently, not just slower.
Recall
An approximate index has a free parameter — ef_search, l_search — that trades accuracy for
latency, so a latency number on its own cannot tell a fast index from an inaccurate one. Every KNN
run is therefore scored for recall@k, reported as mean / 5th percentile in the summary table and in
full in the JSON and CSV output. The 5th percentile is shown because a mean can look healthy while a
tail of queries is answered badly.
Ground truth is computed by crud-bench itself and shared by every engine, rather than taken from each
engine's own bruteforce leg. Scoring an engine against itself measures whether its index agrees with
its own exact path — useful for tracking regressions, but not a number that can sit beside another
engine's, because a quirk shared by an engine's exact and approximate paths cancels out and divergent
metric definitions leave every engine near 1.0 against itself. A useful side effect: each engine's
own bruteforce leg is scored too, and should read 1.000. Anything less is a metric divergence or an
engine bug.
Recall needs a reproducible corpus, so a vector config must set seed (or the run must pass
--corpus-seed). Row content then becomes a pure function of the seed and the sample index, which
also means the same dataset is benchmarked across engines and across runs. Without a seed the KNN
runs still execute and report latency, and print why recall is unavailable.
The answer key is a pure function of its inputs — both seeds, the sample count, top_k, the metric,
the field, and the value template — so it is computed once, cached under --ground-truth-cache
(default .crud-bench-gt, gitignored), and reused by every subsequent engine and run.
cargo run -r -- -d surrealdb -s 100000 -c 12 -t 24 --config config/vector.toml
Databases
Dry
This benchmark does not interact with any datastore, allowing the overhead of the benchmark implementation, written in Rust, to be measured.
cargo run -r -- -d dry -s 100000 -c 12 -t 24 -r
ArangoDB
ArangoDB is a multi-model database with flexible data modeling and efficient querying.
cargo run -r -- -d arangodb -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running ArangoDB instance use the following command:
cargo run -r -- -d arangodb -e http://127.0.0.1:8529 -s 100000 -c 12 -t 24 -r
Dragonfly
Dragonfly is an in-memory, networked, datastore which is fully-compatible with Redis and Memcached APIs.
cargo run -r -- -d dragonfly -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running Dragonfly instance use the following command:
cargo run -r -- -d dragonfly -e redis://:[email protected]:6379 -s 100000 -c 12 -t 24 -r
Fjall
Fjall is a transactional, ACID-compliant, embedded, key-value datastore, written in safe Rust, and based on LSM-trees.
cargo run -r -- -d fjall -s 100000 -c 12 -t 24 -r
KeyDB
KeyDB is an in-memory, networked, datastore which is a high-performance fork of Redis, with a focus on multithreading.
cargo run -r -- -d keydb -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running KeyDB instance use the following command:
cargo run -r -- -d keydb -e redis://:[email protected]:6379 -s 100000 -c 12 -t 24 -r
LMDB
LMDB is a transactional, ACID-compliant, embedded, key-value datastore, based on B-trees.
cargo run -r -- -d lmdb -s 100000 -c 12 -t 24 -r
Map
An in-memory concurrent, associative HashMap in Rust.
cargo run -r -- -d map -s 100000 -c 12 -t 24 -r
MDBX
MDBX is a transactional, key-value, memory-mapped, B-Tree storage engine without WAL.
cargo run -r -- -d mdbx -s 100000 -c 12 -t 24 -r
MongoDB
MongoDB is a NoSQL, networked, ACID-compliant, document-oriented database, with support for unstructured data storage.
cargo run -r -- -d mongodb -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running MongoDB instance use the following command:
cargo run -r -- -d mongodb -e mongodb://root:[email protected]:27017 -s 100000 -c 12 -t 24 -r
MySQL
MySQL is a networked, relational, ACID-compliant, SQL-based database.
cargo run -r -- -d mysql -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running MySQL instance use the following command:
cargo run -r -- -d mysql -e mysql://root:[email protected]:3306/bench -s 100000 -c 12 -t 24 -r
Neo4j
Neo4j is a graph database management system for connected data.
cargo run -r -- -d neo4j -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running Neo4j instance use the following command:
cargo run -r -- -d neo4j -e '127.0.0.1:7687' -s 100000 -c 12 -t 24 -r
Postgres
Postgres is a networked, object-relational, ACID-compliant, SQL-based database.
cargo run -r -- -d postgres -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. Postgres runs parallel queries and parallel index builds
in POSIX shared memory (/dev/shm). Docker limits this to 64 MB, so crud-bench sets --shm-size from work_mem and
maintenance_work_mem, up to half of the memory of the host. To connect to an already-running Postgres instance use the
following command:
cargo run -r -- -d postgres -e 'host=127.0.0.1 user=postgres password=postgres' -s 100000 -c 12 -t 24 -r
ReDB
ReDB is a transactional, ACID-compliant, embedded, key-value datastore, written in Rust, and based on B-trees.
cargo run -r -- -d redb -s 100000 -c 12 -t 24 -r
Redis
Redis is an in-memory, networked, datastore that can be used as a cache, message broker, or datastore.
cargo run -r -- -d redis -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to an already-running Redis instance use the following command:
cargo run -r -- -d redis -e redis://:[email protected]:6379 -s 100000 -c 12 -t 24 -r
RocksDB
RocksDB is a transactional, ACID-compliant, embedded, key-value datastore, based on LSM-trees.
cargo run -r -- -d rocksdb -s 100000 -c 12 -t 24 -r
ScyllaDB
ScyllaDB is a distributed, NoSQL, wide-column datastore, designed to be compatible with Cassandra.
cargo run -r -- -d scylladb -s 100000 -c 12 -t 24 -r
The above command starts a Docker container automatically. To connect to a already-running ScyllaDB cluster use the following command:
cargo run -r -- -d scylladb -e 127.0.0.1:9042 -s 100000 -c 12 -t 24 -r
SlateDB
SlateDB is an embedded storage engine built as a log-structured merge-tree on object storage. It provides bottomless storage capacity and high durability by leveraging object storage (S3, GCS, MinIO, and more).
cargo run -r -- -d slatedb -s 100000 -c 12 -t 24 -r
SQLite
SQLite is an embedded, relational, ACID-compliant, SQL-based database.
cargo run -r -- -d sqlite -s 100000 -c 12 -t 24 -r
SurrealDB
cargo run -r -- -d surrealdb -s 100000 -c 12 -t 24 -r
[!NOTE] The embedded engine tracks the published version closest to SurrealDB's
main, prereleases included, rather than the latest stable release — crud-bench exists to monitor SurrealDB as it is developed, so it should sit where the development is. Server mode already does the same by pulling thesurrealdb/surrealdb:nightlyimage. Embedded and server therefore benchmark different builds, and a result should say which it used.
Specify a custom endpoint using -e or --endpoint to benchmark a custom deployment:
| Endpoint | Meaning |
|---|---|
server:memory |
Docker server with in-memory storage engine |
server:surrealkv |
Docker server with SurrealKV storage engine |
memory |
Embedded with in-memory storage engine. |
surrealkv:<path> (surrealkv:/tmp/db) |
Embedded with SurrealKV storage engine. |
ws://..., wss://..., http://..., https://... |
Remote server you manage yourself (no Docker started by crud-bench). |
The surrealdb adapter links the 4.x SDK and only talks to SurrealDB 4, which no longer offers the RocksDB storage engine. To benchmark SurrealDB 3.x, including RocksDB, use -d surrealdb3, which links the 3.x SDK and starts the surrealdb/surrealdb:v3 image. It accepts the same endpoints as above plus server:rocksdb and rocksdb:<path>, and defaults to server:rocksdb.
SurrealKV
SurrealKV is a versioned, transactional, ACID-compliant, embedded key-value database implemented in Rust using an LSM (Log-Structured Merge) tree and B+tree architecture.
cargo run -r -- -d surrealkv -s 100000 -c 12 -t 24 -r
SurrealMX
SurrealKV is an embedded, in-memory, lock-free and wait-free, transactional, embedded key-value database engine implemented in Rust.
cargo run -r -- -d surrealmx -s 100000 -c 12 -t 24 -r
SurrealDB local benchmark
To run the benchmark against an already running SurrealDB instance, follow the steps below.
Start a SurrealDB server:
surreal start --allow-all -u root -p root surrealkv:/tmp/db
Then run crud-bench with the surrealdb database option:
cargo run -r -- -d surrealdb -e ws://127.0.0.1:8000 --pid "$(pgrep -x surreal)" -s 100000 -c 12 -t 24 -r
The --pid option makes the CPU, Memory, Reads and Writes columns measure the server. Without it, they measure
crud-bench. See Resource columns.
SurrealDB Authentication
When benchmarking SurrealDB (including surrealdb, and surrealds), you can configure authentication credentials using environment variables.
Environment Variables
SURREALDB_USER: Username for SurrealDB authentication (default:root)SURREALDB_PASS: Password for SurrealDB authentication (default:root)
These environment variables are used in two scenarios:
- Docker Container Startup: When crud-bench automatically starts a SurrealDB Docker container (
-d surrealdbwith no endpoint, or with-e server:memory/-e server:surrealkv), it uses these credentials to configure the database server - Client Authentication: When connecting to SurrealDB instances (both embedded and networked), the benchmark client uses these credentials to authenticate
Usage Examples
Using default credentials (root/root):
cargo run -r -- -d surrealdb -s 100000 -c 12 -t 24 -r
Using custom credentials via environment variables:
export SURREALDB_USER=admin
export SURREALDB_PASS=secure_password
cargo run -r -- -d surrealdb -s 100000 -c 12 -t 24 -r
One-line command with custom credentials:
SURREALDB_USER=admin SURREALDB_PASS=secure_password cargo run -r -- -d surrealdb -e ws://127.0.0.1:8000 -s 100000 -c 12 -t 24 -r
Connecting to an external SurrealDB instance with custom credentials:
First, start your SurrealDB instance with custom credentials:
surreal start --allow-all -u admin -p secure_password surrealkv:/tmp/db
Then run the benchmark with matching credentials:
SURREALDB_USER=admin SURREALDB_PASS=secure_password cargo run -r -- -d surrealdb -e ws://127.0.0.1:8000 -s 100000 -c 12 -t 24 -r
Note: When using the automatically started Docker containers, the environment variables configure both the server and client credentials, ensuring they match. When connecting to external instances, ensure the environment variables match the credentials configured on your SurrealDB server.
SurrealDS - Multi-Instance Distributed Benchmark with TiKV
SurrealDS (SurrealDB Distributed System) enables benchmarking against multiple SurrealDB instances simultaneously, with the primary use case being SurrealDB with TiKV as the distributed storage backend. This is particularly useful for testing SurrealDB's performance characteristics when using TiKV's distributed, transactional key-value storage in production-like environments.
Features
- TiKV Integration: Designed specifically for benchmarking SurrealDB with TiKV distributed storage
- Multi-Instance Support: Connect to multiple SurrealDB instances using a single endpoint configuration
- Round-Robin Load Balancing: Automatically distributes client connections evenly across all configured instances
- Networked Connections Only: Designed for remote SurrealDB instances (supports ws://, wss://, http://, https://)
Prerequisites
TiKV Cluster Setup (Primary Use Case)
Before running SurrealDS benchmarks with TiKV, you need to have a TiKV cluster and multiple SurrealDB instances connected to it. Here's a typical setup:
Start a TiKV cluster (using TiUP or your preferred deployment method):
# Example using TiUP for local testing tiup playground --mode tikv-slimStart multiple SurrealDB instances connected to the TiKV cluster:
# Terminal 1 - Start first SurrealDB instance connected to TiKV surreal start --allow-all -u root -p root --bind 127.0.0.1:8001 tikv://127.0.0.1:2379 # Terminal 2 - Start second SurrealDB instance connected to the same TiKV cluster surreal start --allow-all -u root -p root --bind 127.0.0.1:8002 tikv://127.0.0.1:2379 # Terminal 3 - Start third SurrealDB instance connected to the same TiKV cluster surreal start --allow-all -u root -p root --bind 127.0.0.1:8003 tikv://127.0.0.1:2379
Alternative: Local Testing Without TiKV
For testing the load distribution mechanism without TiKV, you can use independent local instances:
# Terminal 1 - Start first instance on port 8001
surreal start --allow-all -u root -p root --bind 127.0.0.1:8001 surrealkv:/tmp/db1
# Terminal 2 - Start second instance on port 8002
surreal start --allow-all -u root -p root --bind 127.0.0.1:8002 surrealkv:/tmp/db2
# Terminal 3 - Start third instance on port 8003
surreal start --allow-all -u root -p root --bind 127.0.0.1:8003 surrealkv:/tmp/db3
Note: When using independent storage backends (like SurrealKV above), each instance maintains its own separate data. With TiKV, all instances share the same distributed storage, which is the recommended production setup.
Usage
To benchmark against multiple SurrealDB instances backed by TiKV, specify the endpoints separated by semicolons:
cargo run -r -- -d surrealds -e "ws://127.0.0.1:8001;ws://127.0.0.1:8002;ws://127.0.0.1:8003" -s 100000 -c 12 -t 24 -r
How It Works
SurrealDS uses a round-robin algorithm to distribute client connections across all configured SurrealDB endpoints:
- When the benchmark engine starts, it parses the endpoint configuration string
- For each concurrent client creation, the engine selects the next endpoint in sequence
- Connections cycle through endpoints evenly (client 0 → endpoint 0, client 1 → endpoint 1, etc.)
- This ensures balanced load distribution across all SurrealDB instances
When using TiKV as the storage backend, all SurrealDB instances read from and write to the same distributed TiKV cluster, allowing you to benchmark:
- How SurrealDB performs with distributed storage
- Load distribution across multiple SurrealDB query/compute nodes
- TiKV's performance under distributed workloads
- Network overhead and coordination costs
Example with 3 endpoints and 6 concurrent clients:
- Client 0 →
ws://127.0.0.1:8001(SurrealDB instance 1 → TiKV cluster) - Client 1 →
ws://127.0.0.1:8002(SurrealDB instance 2 → TiKV cluster) - Client 2 →
ws://127.0.0.1:8003(SurrealDB instance 3 → TiKV cluster) - Client 3 →
ws://127.0.0.1:8001(wraps around) - Client 4 →
ws://127.0.0.1:8002 - Client 5 →
ws://127.0.0.1:8003
Configuration
The endpoint string must:
- Contain one or more SurrealDB endpoints separated by semicolons (
;) - Use remote connection protocols only:
ws://,wss://,http://, orhttps:// - Point to instances that accept the same root credentials (default:
root/root)
Note: You can customize authentication credentials using the
SURREALDB_USERandSURREALDB_PASSenvironment variables. See the SurrealDB Authentication section for details.
Valid endpoint configurations:
# Local TiKV testing with three SurrealDB instances
-e "ws://127.0.0.1:8001;ws://127.0.0.1:8002;ws://127.0.0.1:8003"
# Production TiKV cluster with remote SurrealDB nodes
-e "ws://surreal-node1.example.com:8000;ws://surreal-node2.example.com:8000;ws://surreal-node3.example.com:8000"
# Single instance (still works, but no load distribution)
-e "ws://127.0.0.1:8000"
Use Cases
SurrealDS is ideal for:
- TiKV Performance Benchmarking: Evaluate SurrealDB's performance when using TiKV as the distributed storage backend
- Distributed Storage Testing: Assess how SurrealDB handles distributed transactional workloads across a TiKV cluster
- Load Balancing Evaluation: Test load distribution across multiple SurrealDB compute nodes sharing the same TiKV storage
- Scalability Assessment: Compare single-node vs. multi-node SurrealDB performance with TiKV
- Production Deployment Simulation: Benchmark configurations that mirror real-world distributed SurrealDB+TiKV deployments
- High Availability Testing: Evaluate performance characteristics of redundant SurrealDB instances backed by TiKV
Loading once, querying many times
Loading a large dataset dominates a run, and reads and scans can be measured many times against one load. --mode splits the two:
# Create and update the dataset, then keep it
crud-bench -d postgres -s 10000000 --mode load
# Read and scan it, as often as needed, without reloading
crud-bench -d postgres -s 10000000 --mode query
all(the default) loads, queries, deletes and runs batches in one run, and cleans up.loadruns creates and updates, then leaves the dataset on disk. For Docker databases the data lives in a named volume (crud-bench-<database>, with-<name>appended when--nameis set) that is emptied at the start of everyloadrun. The volume is left behind afterwards; remove it withdocker volume rmwhen finished.queryruns reads and scans against that dataset. It does not recreate the schema and writes no rows.- Deletes and batches run only under
all, because they consume or mutate the dataset.
A load run writes a manifest to --data-dir recording the database, endpoint, --samples, key type, --random, corpus seed, value template, --persisted, --optimised and --sync. A query run refuses to start if any of these differ from the manifest, and says which. A load run that fails removes the manifest so its partial data cannot be queried.
Pass the same --name and --data-dir to a query run as to its load run. Databases that cannot keep data between processes are refused up front: the in-memory map engine, memory endpoints, server:memory, and Redis, KeyDB, Dragonfly and SurrealMX without --persisted.
Explore related projects
Asabeneh/30-Days-Of-Python
The 30 Days of Python programming challenge is a step-by-step guide to learn the Python programming language in 30 days. This challenge may take more than 100 days. Follow your own pace. These videos may help too: https://www.youtube.com/channel/UC7PNRuno1rzYPb1xLa4yktw
78,711 starsgrafana/grafana
The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
77,894 starsredis/redis
For developers, who are building real-time data-driven applications, Redis is the preferred, fastest, and most feature-rich cache, data structure server, and document and vector query engine.
77,490 starsapache/superset
Apache Superset is a Data Visualization and Data Exploration Platform
75,522 stars