A high-performance LMDB backend for konserve using Project Panama FFI (Java 22+).
- Zero-copy reads via LMDB's memory-mapped architecture
- Custom binary encoding optimized for speed (faster than Fressian/Nippy for common types)
- Two API levels: Full konserve compatibility or Direct API for maximum performance
- Lock-free operations: LMDB provides MVCC, no application-level locking needed
- Extensible type handlers for custom serialization
- Java 22+ (for Project Panama FFI)
- liblmdb native library
The library auto-detects common system paths, so usually just installing the package is enough.
sudo apt install liblmdb0brew install lmdbsudo pacman -S lmdbLMDB is a small, dependency-free C library that compiles in seconds:
# Clone the official LMDB repository
git clone https://git.openldap.org/openldap/openldap.git
cd openldap/libraries/liblmdb
# Build (produces liblmdb.so and liblmdb.a)
make
# Install system-wide (recommended)
sudo make installIf the library is in a non-standard location, set KONSERVE_LMDB_LIB:
export KONSERVE_LMDB_LIB=/path/to/liblmdb.soAdd to your dependencies:
(require '[konserve-lmdb.store] ;; Registers the :lmdb backend
'[konserve.core :as k])
(def lmdb-config
{:backend :lmdb
:path "/tmp/my-store"
:opts {:sync? true}})
;; Create a new store (same as connect for LMDB - no creation step)
(def store (k/create-store lmdb-config))
;; Or connect to existing store
;; (def store (k/connect-store lmdb-config))
;; Check if store exists
(k/store-exists? lmdb-config) ;; => true
;; Standard konserve operations
(k/assoc store :user {:name "Alice" :age 30} {:sync? true})
(k/get store :user nil {:sync? true})
;; => {:name "Alice", :age 30}
(k/update-in store [:user :age] inc {:sync? true})
(k/get-in store [:user :age] nil {:sync? true})
;; => 31
;; Multi-key atomic operations
(k/multi-assoc store {:user1 {:name "Bob"}
:user2 {:name "Carol"}}
{:sync? true})
(k/multi-get store [:user1 :user2 :missing] {:sync? true})
;; => {:user1 {:name "Bob"}, :user2 {:name "Carol"}}
;; List all keys (metadata only - efficient for GC)
(k/keys store {:sync? true})
;; => [{:key :user, :type :edn, :last-write #inst "..."}
;; {:key :user1, :type :edn, :last-write #inst "..."}
;; ...]
;; Binary data
(k/bassoc store :image (byte-array [1 2 3 4]) {:sync? true})
(k/bget store :image
(fn [{:keys [input-stream size]}]
(slurp input-stream))
{:sync? true})
;; Clean up
(k/delete-store lmdb-config)For performance-critical code, use the Direct API which bypasses konserve's metadata tracking:
(require '[konserve-lmdb.store :as lmdb])
(def store (lmdb/connect-store "/tmp/my-store"))
;; Direct put/get - no metadata wrapper, fastest possible
(lmdb/put store :key {:data "value"})
(lmdb/get store :key)
;; => {:data "value"}
;; Batch operations - single transaction
(lmdb/multi-put store {:k1 "v1" :k2 "v2" :k3 "v3"})
(lmdb/multi-get store [:k1 :k2 :k3])
;; => {:k1 "v1", :k2 "v2", :k3 "v3"}
(lmdb/del store :key)
(lmdb/release-store store)Important: Direct API and Konserve API use different storage formats and are not interoperable. Data written with lmdb/put cannot be read with k/get and vice versa. Choose one API for each store.
Multimethod API:
(require '[konserve-lmdb.native :as n]
'[konserve.core :as k])
(def config
{:backend :lmdb
:path "/tmp/my-store"
:map-size (* 1024 1024 1024) ; LMDB map size (default: 1GB)
:flags n/MDB_NORDAHEAD ; Environment flags (see below)
:type-handlers registry ; Custom type handlers for serialization
:opts {:sync? true}})
(def store (k/create-store config))Direct API:
(require '[konserve-lmdb.store :as lmdb]
'[konserve-lmdb.native :as n])
(def store (lmdb/connect-store "/tmp/my-store"
:map-size (* 1024 1024 1024)
:flags n/MDB_NORDAHEAD
:type-handlers registry))Environment Flags:
n/MDB_NORDAHEAD- Don't use read-ahead; reduces memory pressure for large datasetsn/MDB_RDONLY- Open in read-only mode; allows concurrent reading while another process writesn/MDB_NOSYNC- Don't fsync after commit; faster but less durable (use for ephemeral data)n/MDB_WRITEMAP- Use writeable mmap; faster for RAM-fitting DBs but less crash-safen/MDB_MAPASYNC- Async flushes when using WRITEMAP; requires explicitsyncfor durabilityn/MDB_NOTLS- Disable thread-local storage; needed for apps with many user threads on few OS threads
Flags can be combined with bit-or:
(lmdb/connect-store path :flags (bit-or n/MDB_NORDAHEAD n/MDB_NOSYNC))LMDB is a powerful but low-level storage engine. Here are important considerations:
LMDB uses memory-mapped files and POSIX locking. Never store LMDB databases on NFS, CIFS, or other network/remote filesystems - this will cause data corruption.
LMDB's database file never shrinks automatically. Deleted data frees pages internally for reuse, but the file size remains. To reclaim space, copy the database with compaction:
mdb_copy -c /path/to/db /path/to/compacted-dbSet map-size large enough for your expected data. LMDB pre-allocates virtual address space (not physical memory). On 64-bit systems, setting 100GB+ is safe and recommended for growing databases:
(lmdb/connect-store path :map-size (* 100 1024 1024 1024)) ; 100GBFor servers running continuously, be aware that:
-
Stale readers - If a read transaction is abandoned (e.g., thread dies), it prevents space reuse until detected. LMDB has
mdb_reader_check()but it's not exposed here yet. -
Keep transactions short - Long-lived read transactions prevent freed pages from being reclaimed, causing database growth. The konserve and Direct APIs handle this correctly with short-lived transactions.
While MDB_WRITEMAP is faster, it has risks:
- Buggy code can corrupt the database by writing to mapped memory
- Filesystem errors may crash the process instead of returning errors
- Use only when performance is critical and you have good backups
LMDB environments are thread-safe. You can share a single store across all threads. However:
- Write transactions are serialized (one at a time)
- Read transactions provide MVCC isolation
- Don't pass cursors between threads
For custom types (e.g., datahike's Datom), register type handlers:
(require '[konserve-lmdb.buffer :as buf])
;; Create a handler for your type
(def my-handler
(reify buf/ITypeHandler
(type-tag [_] 0x20) ; Tags 0x10-0xFF for custom types
(type-class [_] MyRecord)
(encode-type [_ buf value encode-fn]
;; Write fields to buf
(.putLong buf (:id value))
(encode-fn buf (:data value))) ; Recursive encoding
(decode-type [_ buf decode-fn]
;; Read fields from buf
(->MyRecord (.getLong buf)
(decode-fn buf)))))
;; Create registry and pass to store
(def registry (buf/create-handler-registry [my-handler] {}))
(def store (lmdb/connect-store "/tmp/store" :type-handlers registry))Benchmarks comparing konserve-lmdb against datalevin's raw KV API.
Test setup: 1000 entries, ~50 bytes per value (map with UUID, timestamp, counter)
| Operation | Native | Direct | Konserve | Datalevin |
|---|---|---|---|---|
| Single Put | 557K | 448K | 232K | 347K |
| Single Get | 1.43M | 1.06M | 699K | 768K |
| Batch Put | 3.52M | 1.31M | 375K | 893K |
Operations per second, measured with criterium
Key findings:
- Direct API is faster than datalevin for all operations
- Konserve API adds ~1-2µs overhead per operation for metadata tracking
- Batch operations are 3-10x faster than sequential puts
Run benchmarks yourself:
clojure -M:benchMultimethod API (konserve.core):
(k/create-store config)- Create/open an LMDB store via multimethod dispatch(k/connect-store config)- Connect to existing LMDB store (same as create for LMDB)(k/store-exists? config)- Check if store directory exists(k/delete-store config)- Delete store and all data
Direct API (konserve-lmdb.store):
(lmdb/connect-store path & opts)- Create/open an LMDB store directly(lmdb/release-store store)- Close the store(lmdb/delete-store path)- Delete store and all data
(put store key value)- Store value at key(get store key)- Get value by key(del store key)- Delete key(multi-put store kvs)- Store multiple key-value pairs atomically(multi-get store keys)- Get multiple values
The store implements all standard konserve protocols:
PEDNKeyValueStore- get-in, assoc-in, update-in, dissocPBinaryKeyValueStore- bassoc, bgetPKeyIterable- keys enumerationPMultiKeyEDNValueStore- multi-get, multi-assoc, multi-dissocPLockFreeStore- indicates MVCC-based concurrency
# Run tests
clojure -M:test
# Run benchmarks
clojure -M:bench
# Format code
clojure -M:ffix
# Build jar
clojure -T:build jarCopyright © 2025 Christian Weilbach
Licensed under Eclipse Public License 2.0 (see LICENSE).