Changing electrs's Database Backend (Part 1/7)
Introduction
In this series I'm going to talk about my project in Summer of Bitcoin. This is the first part of what's gonna be seven blog posts about my progress through the project. I'm going to explain what is it that I aim to achieve and my though process throughout the duration of the project. Let's start by introducing the project.
What is electrs?
electrs is an efficient re-implementation of Electrum Server. An Electrum Server serves requests to the Electrum Wallet. Now, let's shift out attention to Bitcoin wallets. A Bitcoin wallet is a piece of hardware/software that stores cryptographic keys to allow you to access your coins or make transactions. It's similar to the wallets we use in the real world. To understand how wallets get your balance or do any of their functionality we have to talk about Bitcoin UTXO.
Bitcoin UTXO
Transactions are at the heart of Bitcoin. As a matter of fact, the whole point of Bitcoin is to create and validate transactions. At its core the most important building block of Bitcoin Transactions is the UTXO (Unspent Transaction Output) set. From the Mastering Bitcoin Book:
UTXO are indivisible chunks of bitcoin currency locked to a specific owner, recorded on the blockchain, and recognized as currency units by the entire network.
In Bitcoin there is no concept of balance. If you receive a transaction you don't "increase" your balance, you just have a new UTXO owned by you.
Which brings us to the question: how do I get my balance?
In order to do so you need to query all the UTXO owned by you. And in turn you need to sequentially scan the entire set of UTXO. This is -- by nature -- a computationally expensive operation. To help with this, a process known as indexing is used to organize the UTXOs in a different structure to efficiently query it.
This is the heart of my project. Choosing the correct index structure for Bitcoin blocks and storing it in a Database. The choice of database here is as important as the index structure. There are two major classes of databases OLTP and OLAP. One is optimized for processing a lot of transactions and the other is optimized for write-intensive workloads. The second one is of interest to us here because it's the workload we are optimizing for. Theses databases usually choose a data structure known as LSM (Log Structured Merge Tree) that uses sequential writes to avoid the cost of Random IO.
electrs's Database
The Database electrs currently uses is RocksDB. RocksDB is written in C++. And since electrs is using Rust we need to bind to the foreign code. The RocksDB rust crate already does this. The problem is the C++ build is painfully slow. Plus, it doesn't build on 32-bit ARM systems. Thus, one of this project's goals is to replace RocksDB with another database. To do this we need to understand basic concepts about RocksDB that's used in electrs, and that's the Column Family. A Column Family in RocksDB is a logical partitioning of key-value pairs. It also supports atomic writes across its domain. They can also be configured independently with different parameters. In order to replace RocksDB we need to find a database that supports much of the same logical partitioning. Also to get a sense of the magnitude of the problem we can have a look at the build times taken by each crate in the project.
RocksDB took more than all the other crates combined.
One other thing to take into consideration is the Index Structures used in electrs and if they can be supported in the new Databases. This isn't really an issue given that they are all key-value stores.
Fist Goal of the Project
The first goal of this project is to replace RocksDB with another database that's faster to build and integrates seamlessly with the existing Index structures. It should also build on 32-bit Arm systems. Finally it should be integrated in a clean way into the project in such a way that it's decoupled from the project as to allow other implementation in the future.
For this goal two databases are considered:
- sled
- LMDB
Different crates for each of them should be considered. Also, indexing performance should a top priority.
In order to test build times we are going to use Rust's cargo --timings
parameter that's generally available since version 1.60
. In order to standardize the testing we should add as a CI step. And finally in order to test the indexing performance. Flushes
could be times and reported as a metric to Prometheus.
In the end, it's a trade-off and we have to make a decision. After gathering enough data we will make a decision as to what we should use.