InventDB
All articles Storage

How a single instance holds 250 million records

InventDB stores each table as a list of segments of 100,000 records. One segment takes every write; the others are sealed and only read. This article explains what a segment contains, how one is sealed, how a query finds the segments it needs, and what the design costs.

A single index over 250 million records is one structure that every insert changes and every query descends. Its upper levels are touched by every write, it is too large to keep in memory, and checking or rebuilding it after a fault means reading all of it.

An InventDB instance holds up to 250 million records, so the storage engine does not keep one index per table. It splits each table, which InventDB calls a type, into segments of 100,000 records. Each segment is a small, complete database with its own record file and its own indexes. Exactly one segment of each type accepts writes. When that segment is full, the engine seals it and opens a new one. A type with 25 million records is 250 segments: 249 sealed and one active.

This article follows a record through that structure: where it is written, what the engine does when its segment fills, how a query finds it again, and what the arrangement costs.

A type is a list of segments

On disk, a type is a directory of numbered segments plus a manifest. The manifest lists every segment, the final record count of each sealed one, and which segment is active. It carries a checksum and is written to a temporary file that is then renamed into place, so after a crash the manifest on disk is either the old one or the new one.

Every segment holds the same set of structures:

StructureWhat it holdsWhat it serves
Record fileEvery version of every record written to the segment, each one encrypted and followed by a CRC32 checksumFetching whole records
Id indexA B-link tree from record id to the record's position in the file, plus a Bloom filter of the idsLookups by id
Value tree, per fieldEvery value of the field with the ids that hold it, in sorted orderWHERE, ranges, ORDER BY
Counts tree, per fieldEach distinct value with its row count and sum, plus running totals and the segment's minimum and maximumCOUNT, GROUP BY, SUM, range pruning
Id-to-value tree, per fieldEach record's value for the fieldJoins, per-row checks, building columns
Column files, per fieldDense arrays of the field's values, written after the segment sealsAggregates over sealed data

Every field is indexed these three ways as it is written, so a segment's indexes are already complete on the day it seals. The 100,000-record bound keeps each tree about four levels deep and bounds the work of sealing. It also gives the read path a unit it can skip: a segment that cannot contain the answer is never opened.

Writes enter one active segment; sealed segments are read-only; reads check each segment's Bloom filter and zone map before its indexes Writes insert, update, delete Active segment seg 0249 takes every write seals at 100,000 records seal SEALED, READ-ONLY seg 0248 100,000 records seg 0247 100,000 records seg 0001 to 0246 seg 0000 100,000 records Reads by id, filter, aggregate each segment's Bloom filter and zone map are checked before its indexes
Writes go only to the active segment. At 100,000 records it is sealed and joins the read-only list, and reads visit only the segments that their Bloom filter and zone map do not rule out.

Writes go to the active segment

Every insert, update and delete for a type goes to its active segment. The engine first appends the write to the write-ahead log and holds the record in memory. In the same call it updates the active segment's field indexes and adds the id to the segment's Bloom filter, so the record can be queried by any field as soon as the write returns. A checkpoint, which the server runs every five seconds, then appends the record to the active segment's record file and adds it to the id index.

The record file is append-only. To append, the engine reserves space with one atomic add on the file's length, which returns the offset where the record goes, and then writes the record at that offset. Two reservations can never overlap, so concurrent appends need no file lock. Reads and writes both pass an explicit offset to the operating system's positioned read and write calls, which means threads never share or move a file cursor and many of them can work on one file at once.

An update does not touch the old bytes. It appends the new version and points the id index at the new offset, and the old version stays in the file as dead space. Only the latest version of a record can be read, so the append-only layout is a write strategy; it does not give you version history or time travel.

Sealing a full segment

Before each insert batch, and again after it, the engine compares the active segment's record count with its capacity. That check is one atomic load and a comparison, so its cost is spread over the 100,000 records that fill a segment. When the segment is full, sealing runs in this order:

  1. The engine takes the manifest's write lock and reads again which segment is active. Several parallel writers can notice the full segment at the same moment; the first to take the lock seals it, and the others find that the new active segment has room and carry on.
  2. It records the full segment's final count in the manifest and creates a new, empty segment.
  3. It switches the active segment with a single atomic store and publishes a new list of segments for readers.
  4. It saves the manifest and flushes the sealed segment's files to disk.
  5. It starts building the sealed segment's column files on a background thread, and builds the segment's full-text index.

No index is rebuilt, because every field index was maintained on every insert. Sealing is a metadata change, a flush and the derived files. Writers carry on in the new segment while the columns are built; until a column file exists, queries answer from the field indexes, which give the same result more slowly.

Readers never wait for a seal. A read takes the type's list of segments once, when it starts. Sealing replaces that list in one step, so a read sees either the old list or the new one, and both describe every record.

Reads fan out and skip

A lookup by id checks the active segment first and then the sealed segments from newest to oldest. Before it descends a segment's id index, it asks that segment's Bloom filter whether the id might be there. Each filter is sized for 100,000 ids at a false-positive rate of 0.01%, which comes to about 1.9 million bits, or 234 KiB, per segment. A Bloom filter never answers no for an id it holds, so skipping a segment on a no is always safe. For a type with 250 segments, the expected number of wasted index descents per lookup is 250 × 0.0001, which is 0.025.

A filtered query runs against each segment's field indexes and merges what they return. Each segment also keeps a zone map per field, which is the smallest and largest value the segment holds, and a range query passes over any segment whose zone map cannot overlap the range. When a range query needs at most 10,000 rows, the engine visits segments one at a time and stops once it has enough; larger requests visit segments in parallel. When one record has copies in two segments, the merge keeps the newer copy. Reading less covers this skipping in detail.

Updating a record in a sealed segment

A sealed segment's record file never changes, so an update to a record that lives there is written to the active segment like any other write. The engine then marks the old copy as superseded by adding the record's id to the type's set of superseded ids. Queries check that set in constant time per row and drop the stale copy from both results and aggregates. The checkpoint saves the set to disk with a checksum, so the marks survive a restart.

Each checkpoint also removes stale entries from the sealed segments' indexes, within one budget of 200 milliseconds per checkpoint cycle that is shared across all types. Once a stale copy has gone from the indexes, its id leaves the superseded set. Until then, queries on that type take paths that filter out stale copies, and those paths cost more than the fastest ones.

What it costs

  • Fan-out grows with the number of segments. Bloom filters and zone maps keep the average number of segments a query opens low, but a query that cannot be pruned, such as a full scan or a range over a field whose values have no relation to insert order, touches every segment.
  • Dead space is not reclaimed in place. Every update leaves the previous version in the record file, so a type that is updated heavily takes more disk space than its live data needs.
  • Old records cost more to update. An update to a record in a sealed segment adds a superseded mark and later cleanup work, and while marks are outstanding some queries on that type run on slower paths.
  • Every field is indexed three ways on every write. That is what makes a record queryable by any field at once, and it is the largest part of the cost of a write. The engine also has a key-value configuration that keeps only the record file and the id index and turns SQL off. On a 4-core test machine it ingested about 67,000 records per second, against 20,000 to 25,000 with full indexing. InventDB Serverless and InventDB SOAR always index every field, because a record has to be queryable by any field the moment it is written.
  • Many files. A sealed segment of a type with 20 fields holds 60 field-index files as well as its record file, id index and column files. On our 10-million-record benchmark database, 84% of the bytes on disk are index trees rather than records.

Using it

There is nothing to configure. Every type in InventDB Serverless, the managed database, and in InventDB SOAR, the database with the business engine, is stored this way, because both run the same engine. Two habits help the engine skip more of it:

  • Give records a field whose value rises over time, such as a creation date or a sequence number. Segments fill in insert order, so each segment then covers a narrow range of that field, and a range query on it passes over most segments.
  • Fetch by id when you have the id. A lookup by id tests one Bloom filter per segment and, on average, descends a single id index.

This request, sent as SQL over HTTP, asks for the 50 most recent orders of the past week. The engine ranks them from the date field's indexes and reads only those 50 records from the record files:

POST /sql
Authorization: Bearer <token>
Content-Type: application/json

{
  "sql": "SELECT order_id, total FROM shop.orders WHERE created_at >= '2026-09-24' ORDER BY created_at DESC LIMIT 50"
}

If you store dates as ISO 8601 strings such as 2026-09-24T10:15:00Z, the engine indexes them as dates, and the value, counts and column structures above all order them by time.