1--- 2title: "DeleteRange: A New Native RocksDB Operation" 3layout: post 4author: 5- abhimadan 6- ajkr 7category: blog 8--- 9## Motivation 10 11### Deletion patterns in LSM 12 13Deleting a range of keys is a common pattern in RocksDB. Most systems built on top of 14RocksDB have multi-component key schemas, where keys sharing a common prefix are 15logically related. Here are some examples. 16 17MyRocks is a MySQL fork using RocksDB as its storage engine. Each key's first 18four bytes identify the table or index to which that key belongs. Thus dropping 19a table or index involves deleting all the keys with that prefix. 20 21Rockssandra is a Cassandra variant that uses RocksDB as its storage engine. One 22of its admin tool commands, `nodetool cleanup`, removes key-ranges that have been migrated 23to other nodes in the cluster. 24 25Marketplace uses RocksDB to store product data. Its key begins with product ID, 26and it stores various data associated with the product in separate keys. When a 27product is removed, all these keys must be deleted. 28 29When we decide what to improve, we try to find a use case that's common across 30users, since we want to build a generally useful system, not one that has many 31one-off features for individual users. The range deletion pattern is common as 32illustrated above, so from this perspective it's a good target for optimization. 33 34### Existing mechanisms: challenges and opportunities 35 36The most common pattern we see is scan-and-delete, i.e., advance an iterator 37through the to-be-deleted range, and issue a `Delete` for each key. This is 38slow (involves read I/O) so cannot be done in any critical path. Additionally, 39it creates many tombstones, which slows down iterators and doesn't offer a deadline 40for space reclamation. 41 42Another common pattern is using a custom compaction filter that drops keys in 43the deleted range(s). This deletes the range asynchronously, so cannot be used 44in cases where readers must not see keys in deleted ranges. Further, it has the 45disadvantage of outputting tombstones to all but the bottom level. That's 46because compaction cannot detect whether dropping a key would cause an older 47version at a lower level to reappear. 48 49If space reclamation time is important, or it is important that the deleted 50range not affect iterators, the user can trigger `CompactRange` on the deleted 51range. This can involve arbitrarily long waits in the compaction queue, and 52increases write-amp. By the time it's finished, however, the range is completely 53gone from the LSM. 54 55`DeleteFilesInRange` can be used prior to compacting the deleted range as long 56as snapshot readers do not need to access them. It drops files that are 57completely contained in the deleted range. That saves write-amp because, in 58`CompactRange`, the file data would have to be rewritten several times before it 59reaches the bottom of the LSM, where tombstones can finally be dropped. 60 61In addition to the above approaches having various drawbacks, they are quite 62complicated to reason about and implement. In an ideal world, deleting a range 63of keys would be (1) simple, i.e., a single API call; (2) synchronous, i.e., 64when the call finishes, the keys are guaranteed to be wiped from the DB; (3) low 65latency so it can be used in critical paths; and (4) a first-class operation 66with all the guarantees of any other write, like atomicity, crash-recovery, etc. 67 68## v1: Getting it to work 69 70### Where to persist them? 71 72The first place we thought about storing them is inline with the data blocks. 73We could not think of a good way to do it, however, since the start of a range 74tombstone covering a key could be anywhere, making binary search impossible. 75So, we decided to investigate segregated storage. 76 77A second solution we considered is appending to the manifest. This file is 78append-only, periodically compacted, and stores metadata like the level to which 79each SST belongs. This is tempting because it leverages an existing file, which 80is maintained in the background and fully read when the DB is opened. However, 81it conceptually violates the manifest's purpose, which is to store metadata. It 82also has no way to detect when a range tombstone no longer covers anything and 83is droppable. Further, it'd be possible for keys above a range tombstone to disappear 84when they have their seqnums zeroed upon compaction to the bottommost level. 85 86A third candidate is using a separate column family. This has similar problems 87to the manifest approach. That is, we cannot easily detect when a range 88tombstone is obsolete, and seqnum zeroing can cause a key 89to go from above a range tombstone to below, i.e., disappearing. The upside is 90we can reuse logic for memory buffering, consistent reads/writes, etc. 91 92The problems with the second and third solutions indicate a need for range 93tombstones to be aware of flush/compaction. An easy way to achieve this is put 94them in the SST files themselves - but not in the data blocks, as explained for 95the first solution. So, we introduced a separate meta-block for range tombstones. 96This resolved the problem of when to obsolete range tombstones, as it's simple: 97when they're compacted to the bottom level. We also reused the LSM invariants 98that newer versions of a key are always in a higher level to prevent the seqnum 99zeroing problem. This approach has the side benefit of constraining the range 100tombstones seen during reads to ones in a similar key-range. 101 102 103{: style="display: block; margin-left: auto; margin-right: auto; width: 80%"} 104 105*When there are range tombstones in an SST, they are segregated in a separate meta-block* 106{: style="text-align: center"} 107 108 109{: style="display: block; margin-left: auto; margin-right: auto; width: 80%"} 110 111*Logical range tombstones (left) and their corresponding physical key-value representation (right)* 112{: style="text-align: center"} 113 114### Write path 115 116`WriteBatch` stores range tombstones in its buffer which are logged to the WAL and 117then applied to a dedicated range tombstone memtable during `Write`. Later in 118the background the range tombstone memtable and its corresponding data memtable 119are flushed together into a single SST with a range tombstone meta-block. SSTs 120periodically undergo compaction which rewrites SSTs with point data and range 121tombstones dropped or merged wherever possible. 122 123We chose to use a dedicated memtable for range tombstones. The memtable 124representation is always skiplist in order to minimize overhead in the usual 125case, which is the memtable contains zero or a small number of range tombstones. 126The range tombstones are segregated to a separate memtable for the same reason 127we segregated range tombstones in SSTs. That is, we did not know how to 128interleave the range tombstone with point data in a way that we would be able to 129find it for arbitrary keys that it covers. 130 131 132{: style="display: block; margin-left: auto; margin-right: auto; width: 70%"} 133 134*Lifetime of point keys and range tombstones in RocksDB* 135{: style="text-align: center"} 136 137During flush and compaction, we chose to write out all non-obsolete range 138tombstones unsorted. Sorting by a single dimension is easy to implement, but 139doesn't bring asymptotic improvement to queries over range data. Ideally, we 140want to store skylines (see “Read Path” subsection below) computed over our ranges so we can binary search. 141However, a couple of concerns cause doing this in flush and compaction to feel 142unsatisfactory: (1) we need to store multiple skylines, one for each snapshot, 143which further complicates the range tombstone meta-block encoding; and (2) even 144if we implement this, the range tombstone memtable still needs to be linearly 145scanned. Given these concerns we decided to defer collapsing work to the read 146side, hoping a good caching strategy could optimize this at some future point. 147 148 149### Read path 150 151In point lookups, we aggregate range tombstones in an unordered vector as we 152search through live memtable, immutable memtables, and then SSTs. When a key is 153found that matches the lookup key, we do a scan through the vector, checking 154whether the key is deleted. 155 156In iterators, we aggregate range tombstones into a skyline as we visit live 157memtable, immutable memtables, and SSTs. The skyline is expensive to construct but fast to determine whether a key is covered. The skyline keeps track of the most recent range tombstone found to optimize `Next` and `Prev`. 158 159| | | 160 161*([Image source: Leetcode](https://leetcode.com/problems/the-skyline-problem/description/)) The skyline problem involves taking building location/height data in the 162unsearchable form of A and converting it to the form of B, which is 163binary-searchable. With overlapping range tombstones, to achieve efficient 164searching we need to solve an analogous problem, where the x-axis is the 165key-space and the y-axis is the sequence number.* 166{: style="text-align: center"} 167 168### Performance characteristics 169 170For the v1 implementation, writes are much faster compared to the scan and 171delete (optionally within a transaction) pattern. `DeleteRange` only logs to WAL 172and applies to memtable. Logging to WAL always `fflush`es, and optionally 173`fsync`s or `fdatasync`s. Applying to memtable is always an in-memory operation. 174Since range tombstones have a dedicated skiplist memtable, the complexity of inserting is O(log(T)), where T is the number of existing buffered range tombstones. 175 176Reading in the presence of v1 range tombstones, however, is much slower than reads 177in a database where scan-and-delete has happened, due to the linear scan over 178range tombstone memtables/meta-blocks. 179 180Iterating in a database with v1 range tombstones is usually slower than in a 181scan-and-delete database, although the gap lessens as iterations grow longer. 182When an iterator is first created and seeked, we construct a skyline over its 183tombstones. This operation is O(T\*log(T)) where T is the number of tombstones 184found across live memtable, immutable memtable, L0 files, and one file from each 185of the L1+ levels. However, moving the iterator forwards or backwards is simply 186a constant-time operation (excluding edge cases, e.g., many range tombstones 187between consecutive point keys). 188 189## v2: Making it fast 190 191`DeleteRange`’s negative impact on read perf is a barrier to its adoption. The 192root cause is range tombstones are not stored or cached in a format that can be 193efficiently searched. We needed to design DeleteRange so that we could maintain 194write performance while making read performance competitive with workarounds 195used in production (e.g., scan-and-delete). 196 197### Representations 198 199The key idea of the redesign is that, instead of globally collapsing range tombstones, 200 we can locally “fragment” them for each SST file and memtable to guarantee that: 201 202* no range tombstones overlap; and 203* range tombstones are ordered by start key. 204 205Combined, these properties make range tombstones binary searchable. This 206 fragmentation will happen on the read path, but unlike the previous design, we can 207 easily cache many of these range tombstone fragments on the read path. 208 209### Write path 210 211The write path remains unchanged. 212 213### Read path 214 215When an SST file is opened, its range tombstones are fragmented and cached. For point 216 lookups, we binary search each file's fragmented range tombstones for one that covers 217 the lookup key. Unlike the old design, once we find a tombstone, we no longer need to 218 search for the key in lower levels, since we know that any keys on those levels will be 219 covered (though we do still check the current level since there may be keys written after 220 the range tombstone). 221 222For range scans, we create iterators over all the fragmented range 223 tombstones and store them in a list, seeking each one to cover the start key of the range 224 scan (if possible), and query each encountered key in this structure as in the old design, 225 advancing range tombstone iterators as necessary. In effect, we implicitly create a skyline. 226 This requires significantly less work on iterator creation, but since each memtable/SST has 227its own range tombstone iterator, querying range tombstones requires key comparisons (and 228possibly iterator increments) for several iterators (as opposed to v1, where we had a global 229collapsed representation of all range tombstones). As a result, very long range scans may become 230 slower than before, but short range scans are an order of magnitude faster, which are the 231 more common class of range scan. 232 233## Benchmarks 234 235To understand the performance of this new design, we used `db_bench` to compare point lookup, short range scan, 236 and long range scan performance across: 237 238* the v1 DeleteRange design, 239* the scan-and-delete workaround, and 240* the v2 DeleteRange design. 241 242In these benchmarks, we used a database with 5 million data keys, and 10000 range tombstones (ignoring 243those dropped during compaction) that were written in regular intervals after 4.5 million data keys were written. 244Writing the range tombstones ensures that most of them are not compacted away, and we have more tombstones 245in higher levels that cover keys in lower levels, which allows the benchmarks to exercise more interesting behavior 246when reading deleted keys. 247 248Point lookup benchmarks read 100000 keys from a database using `readwhilewriting`. Range scan benchmarks used 249`seekrandomwhilewriting` and seeked 100000 times, and advanced up to 10 keys away from the seek position for short range scans, and advanced up to 1000 keys away from the seek position for long range scans. 250 251The results are summarized in the tables below, averaged over 10 runs (note the 252different SHAs for v1 benchmarks are due to a new `db_bench` flag that was added in order to compare performance with databases with no tombstones; for brevity, those results are not reported here). Also note that the block cache was large enough to hold the entire db, so the large throughput is due to limited I/Os and little time spent on decompression. The range tombstone blocks are always pinned uncompressed in memory. We believe these setup details should not affect relative performance between versions. 253 254### Point Lookups 255 256|Name |SHA |avg micros/op |avg ops/sec | 257|v1 |35cd754a6 |1.3179 |759,830.90 | 258|scan-del |7528130e3 |0.6036 |1,667,237.70 | 259|v2 |7528130e3 |0.6128 |1,634,633.40 | 260 261### Short Range Scans 262 263|Name |SHA |avg micros/op |avg ops/sec | 264|v1 |0ed738fdd |6.23 |176,562.00 | 265|scan-del |PR 4677 |2.6844 |377,313.00 | 266|v2 |PR 4677 |2.8226 |361,249.70 | 267 268### Long Range scans 269 270|Name |SHA |avg micros/op |avg ops/sec | 271|v1 |0ed738fdd |52.7066 |19,074.00 | 272|scan-del |PR 4677 |38.0325 |26,648.60 | 273|v2 |PR 4677 |41.2882 |24,714.70 | 274 275## Future Work 276 277Note that memtable range tombstones are fragmented every read; for now this is acceptable, 278 since we expect there to be relatively few range tombstones in memtables (and users can 279 enforce this by keeping track of the number of memtable range deletions and manually flushing 280 after it passes a threshold). In the future, a specialized data structure can be used for storing 281 range tombstones in memory to avoid this work. 282 283Another future optimization is to create a new format version that requires range tombstones to 284 be stored in a fragmented form. This would save time when opening SST files, and when `max_open_files` 285is not -1 (i.e., files may be opened several times). 286 287## Acknowledgements 288 289Special thanks to Peter Mattis and Nikhil Benesch from Cockroach Labs, who were early users of 290DeleteRange v1 in production, contributed the cleanest/most efficient v1 aggregation implementation, found and fixed bugs, and provided initial DeleteRange v2 design and continued help. 291 292Thanks to Huachao Huang and Jinpeng Zhang from PingCAP for early DeleteRange v1 adoption, bug reports, and fixes. 293