-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathatom.xml
6821 lines (6516 loc) · 435 KB
/
atom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://blog.shunzi.tech</id>
<title>YouDieInADream</title>
<updated>2023-01-15T08:13:52.540Z</updated>
<generator>https://github.com/jpmonette/feed</generator>
<link rel="alternate" href="https://blog.shunzi.tech"/>
<link rel="self" href="https://blog.shunzi.tech/atom.xml"/>
<subtitle>The easy way or the right way.</subtitle>
<logo>https://blog.shunzi.tech/images/avatar.png</logo>
<icon>https://blog.shunzi.tech/favicon.ico</icon>
<rights>All rights reserved 2023, YouDieInADream</rights>
<entry>
<title type="html"><![CDATA[OSDI’22 ListDB: Union of Write-Ahead Logs and Persistent SkipLists for Incremental Checkpointing on Persistent Memory]]></title>
<id>https://blog.shunzi.tech/post/ListDB/</id>
<link href="https://blog.shunzi.tech/post/ListDB/">
</link>
<updated>2022-07-13T03:40:00.000Z</updated>
<content type="html"><![CDATA[<blockquote>
<ul>
<li>OSDI’22 ListDB: Union of Write-Ahead Logs and Persistent SkipLists for Incremental Checkpointing on Persistent Memory</li>
</ul>
</blockquote>
<h1 id="osdi22-listdb-union-of-write-ahead-logs-and-persistent-skiplists-for-incremental-checkpointing-on-persistent-memory">OSDI’22 ListDB: Union of Write-Ahead Logs and Persistent SkipLists for Incremental Checkpointing on Persistent Memory</h1>
<h1 id="abstract">Abstract</h1>
<ul>
<li>由于 DRAM 和非易失性主存(NVMM)之间的延迟差异以及 DRAM 的有限容量,传入的写操作经常在基于 LSM 树的键值存储中停顿 stal。本文提出了一种为 NVMM 进行写优化的键值存储 ListDB,以克服 DRAM 和 NVMM 写延迟之间的差距,从而解决写停顿问题。L</li>
<li>istDB 的贡献包括三种新的技术:
<ul>
<li>(i) 字节寻址的 IndexUnified Logging,它增量地将预写日志转换为 SkipList;</li>
<li>(ii) Braided SkipList,一个简单的 NUMA-aware SkipList,它有效地减少了 NVMM 的 NUMA 影响;</li>
<li>(iii) Zipper Compaction,它不复制键值对象向下移动 LSM 树 levels,但是通过就地合并 SkipList 而不阻塞并发读取。</li>
</ul>
</li>
<li>通过使用这三种技术,ListDB 使后台压缩足够快,足以解决臭名昭著的写停顿问题,并显示出比 PACTree 和 Intel Pmem-RocksDB 分别高 1.6 倍和 25 倍的写吞吐量。</li>
</ul>
<h1 id="introduction">Introduction</h1>
<ul>
<li>NVM 延迟和 DRAM 相当,非易失,字节寻址,以内存总线速度来操作。</li>
<li>NVM 中大数据集的定位和检索往往采用一个高效的持久化索引结构,并考虑 NVM 的器件特征。
<ul>
<li>NVMM-only 的持久性索引:[11,13,24,35,45,49,56,63]
<ul>
<li>VLDB’15 Persistent B+-Trees in Non-Volatile Main Memory</li>
<li>ATC’20 Lock-free Concurrent Level Hashing for Persistent Memory</li>
<li>FAST’18 Endurable Transient Inconsistency in Byte-Addressable Persistent B+-Tree
<ul>
<li><strong>aka. FAST and FAIR B+tree</strong></li>
</ul>
</li>
<li>FAST’17 WORT: Write Optimal Radix Tree for Persistent Memory Storage Systems.</li>
<li>FAST’19 Write-Optimized Dynamic Hashing for Persistent Memory.
<ul>
<li><strong>aka. CCEH</strong></li>
</ul>
</li>
<li>SIGMOD’16 FPTree: A Hybrid SCM-DRAM Persistent and Concurrent B-Tree for Storage Class Memory</li>
<li>FAST’11 Consistent and Durable Data Structures for Non-Volatile Byte-Addressable Memory.</li>
<li>FAST’15 NV-Tree: Reducing Consistency Cost for NVM-based Single Level Systems.</li>
</ul>
</li>
<li>混和 DRAM+NVM 持久性索引:[38, 40, 49, 63]
<ul>
<li>VLDB’20 LB+Trees: Optimizing Persistent Index Performance on 3DXPoint Memory.</li>
<li>VLDB’20 Dash: Scalable Hashing on Persistent Memory</li>
<li>SIGMOD’16 FPTree: A Hybrid SCM-DRAM Persistent and Concurrent B-Tree for Storage Class Memory.</li>
<li>FAST’15 NV-Tree: Reducing Consistency Const for NVM-based Single Level Systems.</li>
</ul>
</li>
<li>还开发了一些使用这种持久索引和后台工作线程管理大型数据集的键值存储 [12, 29, 30, 57, 60]
<ul>
<li>ASPLOS’20 FlatStore: An Efficient LogStructured Key-Value Storage Engine for Persistent Memory</li>
<li>FAST’19 SLM-DB: SingleLevel Key-Value Store with Persistent Memory</li>
<li>ATC’18 Redesigning LSMs for Nonvolatile Memory with NoveLSM</li>
<li>OSDI’21 Nap: A Black-Box Approach to NUMA-Aware Persistent Memory Indexes</li>
<li>ATC’17 HiKV: A Hybrid Index Key-Value Store for DRAMNVM Memory Systems</li>
</ul>
</li>
</ul>
</li>
<li>针对 NVM 设计的索引结构,比如 FAST and FAIR, CCEH, PACTree,提供比基于磁盘的同类产品高数量级的性能。性能仍然低于 DRAM 索引。
<ul>
<li>因为 NVM 比 DRAM 性能要差,更高的延迟,更低的带宽,NUMA 效应更敏感,以及需要更大的数据访问粒度(256B XPLine).</li>
</ul>
</li>
<li>混和 DRAM+NVM 索引以及键值存储,目的是为了发挥 DRAM 的优势,避免 NVM 的短板,所以把索引的复杂度给丢给了 DRAM。在这项工作中,我们质疑这种忽略字节寻址性、将整个索引保存在 DRAM 中、仅将 NVMM 用作日志空间的混合方法是否可取,因为它有两个主要限制:
<ul>
<li>DRAM 容量比较小,如果数据集索引比较大,或者 DRAM 空间和其他进程共享,导致 DRAM 需要进行内存 Swapping,索引性能就会受到影响</li>
<li>易失的 DRAM 索引需要故障恢复之后重建,恢复时间很关键。周期性做 checkpoint 可以节省恢复时间,但是导致更高的写延迟,因为会阻塞并发写入</li>
</ul>
</li>
<li>作者提出了一个异步增量 checkpoint 机制,后台合并小的、高性能的 DRAM 索引到一个持久性索引来加速数据恢复。ListDB 是一个写优化的 LSM 键值存储,实现了类似于 DRAM 索引的性能,并通过把 DRAM 索引刷回到 NVM 的方式来避免 DRAM 索引无限增长,同时实现了类似于 DRAM 索引的高性能。ListDB 缓冲了批量的插入到一个小的 DRAM 索引中,并运行后台压缩任务来增量地 Checkpointing 对于 NVMM 的缓冲写入,而不需要进行数据拷贝。ListDB 将日志条目重组为 SkipList,而不是将整个 volatile 索引刷新到 NVMM。同时,这样的 SkipLists 就地合并,减少了 NUMA 影响,而不会阻塞并发的读查询。</li>
<li>三个关键技术:
<ul>
<li>快速写缓冲刷回:ListDB 统一了 WAL 和跳表,使用 Index-Unified Logging (IUL),ListDB 只将每个键值对象作为日志条目写入 NVMM 一次。利用 NVMM 的字节可寻址性,IUL 以一种惰性的方式将日志条目转换为 SkipList 元素,这掩盖了日志记录和 MemTable flush 开销。因此,它使MemTable flush 吞吐量高于 DRAM 索引的写吞吐量,从而解决了写停顿问题。</li>
<li>减小 NUMA 效应:通过使上层指针只指向同一个 NUMA 节点上的 SkipList 元素,Braided SkipList 有效地减少了远程 NUMA 节点访问的数量</li>
<li>就地合并排序的快速 Compaction:Zippers Compaction 合并两个 Skiplist,不会阻塞读取操作。通过避免复制,Zipper Compaction 缓解了写放大的问题,并快速有效地减少 SkipLists 的数量,以提高读和恢复性能</li>
</ul>
</li>
<li>我们的性能研究表明,ListDB 的写性能优于最先进的基于 NVMM 的键值存储。对于读性能,ListDB 依赖于经典的缓存技术。</li>
</ul>
<h1 id="background-and-motivation">Background and Motivation</h1>
<h2 id="hybrid-dramnvmm-key-value-store">Hybrid DRAM+NVMM Key-Value Store</h2>
<ul>
<li>基于 NVMM 的索引常常因为需要使用 memory fence 的相关指令来保证 cacheline 持久化,引入了较大的开销。为了避免这,NVTree 和 FPTree 在 DRAM 上存储内部节点,在 NVM 上存储叶子节点,内部节点在系统故障之后可以恢复出来,但是可以从持久的叶子节点中重建。基于此,对于内部节点的写入不需要保证 failure-atomic</li>
<li>FlatStore 采用了一种相当激进的方法,即 NVMM 仅用作日志空间,其中键值对象以插入顺序而不是键顺序添加,而索引驻留在 DRAM 中。因此,FlatStore 必须在系统崩溃后从持久的日志条目重建一个不稳定的索引。为了减轻昂贵的恢复开销,FlatStore 建议定期在 NVMM 上 checkpoint DRAM索引。然而,一个简单的同步检查点(如FlatStore)在阻塞写入时获取全局快照,导致不可接受的高尾延迟。</li>
</ul>
<h2 id="lsm">LSM</h2>
<ul>
<li>更好的方法是异步增量 checkpoint,它只 checkpoints 当前检查点和最后一个检查点状态之间的差异。Log Structured Merge (LSM)树是一个经典的索引,它随着时间的推移合并检查点数据 [10, 17, 20, 33, 36, 48, 54].</li>
</ul>
<h3 id="基本操作">基本操作</h3>
<ul>
<li>写过程不做介绍</li>
<li>Compaction 不做介绍 [2, 9, 21, 29, 37, 41–43, 46, 53, 55]</li>
<li>读操作不做介绍,LSM 读比较慢,但是 LSM 比 B+tree 应用更广泛,因为读性能可以通过缓存很有效提升</li>
</ul>
<h3 id="side-effect-of-write-buffer-write-stall">Side Effect of Write Buffer: Write Stall</h3>
<ul>
<li>写 buffer 处理写入很快,但是在 LSM 里有一些人工调控限制了写 Buffer,导致很高的延迟。即 compaction 比较慢的时候会阻塞前台的 imm 刷回。相似的,如果 SST 没有很快被合并排序,重叠的 SST 也会增加,读性能下降。大部分 LSM 会阻塞客户端的写入,直到 compaction 完成以及有空间容纳新的 memtable。这就是写停顿,如果写停顿出现,这时候插入吞吐就受到持久存储性能的限制,而没法从 DRAM 中获益</li>
</ul>
<h3 id="写放大">写放大</h3>
<p><strong>多层和双层压缩</strong></p>
<ul>
<li>基于拷贝的 Compaction 允许并发的读查询访问旧的 SST,同时创建新的 SSTs。但是基于拷贝的 Compaction 要求重复拷贝相同的对象到新的 SSTs。一个键值对被拷贝到一个新文件的次数即为写放大,现有研究估计可能高达 40。如果使用 leveled compaction 的话,写放大会尤其严重,而且会有大量的 levels。因为 leveled compaction 限制了每层的 SSTables 数量,并且避免了单层里的数据重叠。</li>
<li>因为 NVM 字节寻址,因此可以考虑使用单层的持久化索引来替代多层 SSTs。SLM-DB 就使用了两层,一层 Memtable 和一个在 NVM 上的单层的 B+tree,使用两层设计,Memtable 缓冲了多个 KV 键值对然后插入到一个大的持久性索引,例如,对于多次写操作只遍历一次大型持久索引,并且它比单个持久索引产生更高的写吞吐量</li>
</ul>
<figure data-type="image" tabindex="1"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled.png" alt="Untitled" loading="lazy"></figure>
<p><strong>解耦归并排序和 Flush</strong></p>
<ul>
<li>双层设计的主要问题是持久性索引的大小影响合并易失索引到持久性索引的性能,也就无法让写性能独立于 NVMM 性能。因为 Memtable 不再是 flush 到底层,而是归并排序到大的慢的持久性索引。因为 NVMM 有更高的延迟,归并排序吞吐远低于易失索引的插入吞吐,特别是持久性索引比较大的时候</li>
<li>为了缓解这个问题,大多数键值存储采用了一个中间持久化 buffer level L0。即执行 flush 而不进行归并排序,下图展示了一个三层设计,通过分离归并排序和 flush,Memtable 可以刷回到 NVMM 更快,flush 的吞吐也就和数据库的大小独立开来</li>
<li>该设计的一个缺点就是导致了更多的重叠 SSTs,影响了查询性能。鉴于其较差的索引性能,中间持久缓冲区级别似乎与预写日志没有太大区别。另外,键值数据通常写到存储上至少两次,一次 WAL,一次 Memtable flush</li>
<li>TRIAD, WiscKey, FlatStore 避免了重复写入。
<ul>
<li>TRIAD 直接把 commit log 当作未排序的 L0 SST,为了高效检索 L0,TRIAD 为每个 L0 SST 维护了一个小的索引。索引不存储键值,只按键顺序存储每个对象的偏移。虽然 TRIAD 减少了 I/O,但每个 Memtable 刷回创建一个索引文件,然后调用开销较大的 fsync 来持久化。然而,考虑到 L0 SSTables 之间的高度重叠,以及 L0 SSTables 将很快合并到 L1 SSTables 的事实,是否应该以非常高的成本为每个 L0 SSTable 创建和持久化一个单独的索引文件是值得怀疑的</li>
</ul>
</li>
</ul>
<h2 id="numa-effects">NUMA Effects</h2>
<ul>
<li>相比于 DRAM,NVM 因为其更低的带宽对 NUMA 效应更敏感。也就是因为这,CCEH, FAST and FAIR 这些索引的扩展性就比较一般,由于非常规的cacheline 访问和 NUMA 效应</li>
<li>有研究建议限制每个 Socket 的写线程为 4-6 个线程。
<ul>
<li>SIGMOD’21 Maximizing Persistent Memory Bandwidth Utilization for OLAP Workloads.</li>
</ul>
</li>
<li>Nap 通过在 NVMM 常驻索引上覆盖一个 DRAM 索引来隐藏 NUMA 效果,这样 DRAM 索引就可以吸收远程 NUMA 节点访问。但是,存储在NVMM中的数据已经在内存地址空间中,而且 NVMM 的延迟与 DRAM 相当。因此,使用 DRAM 作为 NVMM 上的快速缓存层并在 DRAM 和 NVMM 之间来回复制数据可能会造成浪费。例如,EXT4-DAX 和 NOVA[61] 等 NVMM 文件系统不使用页面缓存,而是直接访问 NVMM</li>
<li>缓解 DRAM 上的 NUMA 效应有很多种方法,包括 基于哈希分片的 Delegation 和 Node Replication
<ul>
<li>Delegation:指定的 worker 线程分配给一个具体的键范围,负责其所有操作。因此客户端线程需要和 worker 线程通信并使用消息传递来委派操作。因为消息传递的开销,Delegation 执行的性能不是最优的,特别是对于索引操作这样的轻量级任务 [4, 6, 7, 44]
<ul>
<li>ATC’11 A Case for NUMAAware Contention Management on Multicore Systems.</li>
<li>HotPar’13 Using Elimination and Delegation to Implement a Scalable NUMA-Friendly Stack</li>
<li>ASPLOS’17 Black-Box Concurrent Data Structures for NUMA Architectures.</li>
<li>PPoPP’12 CPHASH: A Cache-Partitioned Hash Table.</li>
</ul>
</li>
<li>Node Replication:实现了一个 NUMA 感知的共享日志,它用于对跨 NUMA 节点复制的数据结构重放相同的操作。但是,这将消耗内存来跨多个 NUMA 节点复制相同的数据结构。此外,由于 NUMA 节点数量增加,跨节点通信导致性能下降。考虑到 Optane DCPMM 的带宽远低于DRAM,复制会加剧低带宽问题
<ul>
<li>ASPLOS’17 Black-Box Concurrent Data Structures for NUMA Architectures</li>
</ul>
</li>
</ul>
</li>
</ul>
<h1 id="design">Design</h1>
<h2 id="three-level-architecture">Three-Level Architecture</h2>
<ul>
<li>三层:Memable,L0,L1 Persistent Memtables (PMTables)
<ul>
<li>Memtable 和 PMTables 本质上是相同的跳表,但是 PMTable 有额外的元数据,因为是从 WAL 转换过来的</li>
<li>ListDB使用 SkipList 作为所有级别的核心数据结构,因为它支持字节可寻址的就地合并排序,并避免了写放大问题[21,41,53]</li>
</ul>
</li>
<li>ListDB 在 NVMM 中使用一个中间持久缓冲区级别 L0 。对于 L0,MemTable 被 flush 到 NVMM 而不需要进行合并排序,这使得 flush 吞吐量与下一级持久索引大小无关。L0 积累的 MemTables (L0 PMTables)通过压缩逐渐合并到较大的 L1 PMTable 中。为了管理多个 PMTables, ListDB使用一个名为MANIFEST的元数据对象指向每个 SkipList 的开头。</li>
</ul>
<figure data-type="image" tabindex="2"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%201.png" alt="Untitled" loading="lazy"></figure>
<h2 id="index-unified-logging">Index-Unified Logging</h2>
<ul>
<li>目标就是将 Memtable 刷回到 NVMM 不需要拷贝简直对象</li>
</ul>
<h3 id="conversion-of-iul-into-skiplist">Conversion of IUL into SkipList</h3>
<ul>
<li>Index-Unified Logging (IUL) 通过按照跳表元素的形式来分配和写入日志项,实现 WAL 和跳表的统一。图 4 展示了其中 Entry 的结构,同时作为日志项和跳表元素。当插入一个键值对到 Memtable,其对象和元数据(operation 以及 LSN)是已知的,对应的日志项被写入和持久化到 NVMM 上,对应的跳表指针初始化为 NULL。之后,一个 Compaction 线程把对应的 Memtable 刷回的时候,日志项被转换成跳表元素,重用日志项中对应的键值</li>
<li>但是跳表元素需要额外的信息,即键的顺序信息,该信息以跳表指针维护在 Memtable 中。当把日志转换成 PMTable 的时候,相应的 Memtable 元素的地址被简单的转换成 NVMM 的地址(通过偏移量转换)。跳表指针转换成 NVMM 地址之后,IUL 中的项也就变成了跳表元素</li>
<li>最后更新 MANIFEST 来让新的 L0 PMTable 生效,并让 Immutable Memtable 在一个 failure-atomic 事务中失效。</li>
</ul>
<figure data-type="image" tabindex="3"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%202.png" alt="Untitled" loading="lazy"></figure>
<h3 id="memtable-flush-without-clflush">MemTable Flush without clflush</h3>
<ul>
<li>跳表指针写入到日志项时不需要 clflush,因为本身写入的只是顺序,不是数据,顺序完全可以恢复出来。</li>
<li>不需要显示 flush 也就是说利用 CPU 自己的缓存机制来管理数据的刷回,也就实现了对于相同 XPLine 的写入可以被缓冲,8B 小写也就不会转换成 256B 的 RMW,不仅延后了 RMW,也避免了后台 Compaction 线程受到 RMW 带来的延迟影响</li>
</ul>
<h3 id="walk-through-example">Walk-Through Example</h3>
<ul>
<li>流程如上图 5 所示,假设插入的数据顺序为 503,921,3。每个客户端线程在它提交之前持久化对象、元数据和 NULL 指针到日志。然后后台线程标记 Memtable 为 immutable 并创建一个新的 Memtable。客户端线程再插入了 716 和 217。</li>
<li>后台 Compaction 线程刷回 Immutable Memtable,把相应的 Memtable 元素指针转换成 IUL 偏移量,然后替换掉对应的指针,从而成为跳表。</li>
</ul>
<h3 id="checkpointing-l0-pmtable">Checkpointing L0 PMTable</h3>
<ul>
<li>虽然日志条目现在被转换为 L0 PMTable 元素,但是日志空间和 L0 PMTable 空间之间的边界(如图6(a)中的粗虚线所示)没有移动,因为它不能保证新的 L0 PMTable 的指针是持久的。只有对更新的指针显示调用了 clflush 指令才会移动边界,在我们的实现中,后台线程批量地持久化 L0 PMTables 的脏cachelines。此操作称为 checkpointing。图6(b)显示了指针显式持久化后的NVMM布局。一旦PMTable被 checkpointing,就可以移动日志空间的边界以减少需要恢复的日志条目的数量,如图6(b)所示。</li>
</ul>
<figure data-type="image" tabindex="4"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%203.png" alt="Untitled" loading="lazy"></figure>
<h3 id="lazy-group-checkpointing">Lazy Group Checkpointing</h3>
<ul>
<li>Checkpointing 目的是为了减少恢复时间。但是 ListDB 尽可能推迟 Checkpointing,因为调用 clflush 开销太大了,即便 L0 PMTables 没有被持久化,也不会影响崩溃一致性,因为会被当作日志来恢复。其顺序也可以重建</li>
<li>作者实现中,多个 L0 PMTables 被分组,脏的 cachelines 批量进行持久化。也就是所谓的 lazy group checkpointing。本身就是在日志大小、恢复时间以及 flush 吞吐量方面做妥协。checkpointing 越频繁,flush 越慢,日志大小越小,恢复越快。</li>
<li>Zipper compaction 持久化指针很快,可以避免 L0 的表数量太多。即使 IUL 不持久化任何 L0 PMTable,当将 L0 PMTable 合并到 L1 PMTable 时,Zipper 压缩持久化指针的速度很快,而且 IUL 的恢复时间比同步检查点要短得多</li>
</ul>
<h2 id="numa-effects-for-skiplist">NUMA Effects for SkipList</h2>
<ul>
<li>ListDB 采用了 NUMA 感知的数据结构,与委托和节点复制相比,该结构在最小化 NUMA 互连争用方面更具可伸缩性和有效性</li>
</ul>
<h3 id="numa-aware-braided-skiplist">NUMA-aware Braided SkipList</h3>
<ul>
<li>跳表的一个特点:每一层的 list 必然是最底层 list 的有序子集。上层的指针是概率选择出来的,不影响查询的正确性。但是,<strong>上层不需要是下一层的子列表,只要是底层的子列表即可</strong>。即使搜索没有找到更接近上层搜索键的键,搜索也会回到更低的层,最终搜索到包含所有有序键的底层。</li>
<li>Braided SkipList 就是利用这个特点来减小 NUMA 效应。上层指针忽略远程 NUMA 节点中的 SkipList 元素;例如,每个元素的上层指针指向同一个NUMA 节点中具有更大键的元素。与 NUMA-oblivious 传统的 SkipList 相比,Braided SkipList 把远程内存访问次数减少到 1/N,其中 N 是 NUMA 节点的数目</li>
<li>如下例子,为了便于显示,NUMA节点 1 中的上层颠倒了。
<ul>
<li>NUMA 0 上的元素 3 指向了相同 NUMA 节点的元素 7,但是没有指向 NUMA 1 上的元素 5(原始的跳表就会指向 5)。</li>
<li>尽管如上维护指针,查询还是正确的。
<ul>
<li>比如在 NUMA 0 上的线程查询 5。首先找到 3 和 9,因为 9 更大,向下移动找到 3 和 7,继续下移,找到了 3 和 4。因为 5 大于 4,那么继续下移动,这时候检查 4 的下一个指针。也就找到了 NUMA 1 上的 5</li>
</ul>
</li>
</ul>
</li>
<li>在作者的 Braided SkipList 实现中,NUMA ID 被嵌入到 64 位虚拟地址的额外 16 位中,类似于 pointer swizzling 的操作,这样它就可以使用 8 字节的原子指令,而不是较重的 PMDK 事务。为了直接引用,Braided SkipList 通过 masking 额外的 16 位来恢复 SkipList 元素的虚拟内存地址。</li>
</ul>
<figure data-type="image" tabindex="5"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%204.png" alt="Untitled" loading="lazy"></figure>
<h2 id="zipper-compaction">Zipper Compaction</h2>
<ul>
<li>Zipper Compaction 通过只修改指针来归并排序 L0 和 L1 的 PMTables,不会阻塞并发的读。就地归并排序避免了写放大,提升了 Compaction 吞吐</li>
<li>基于跳表的特性,部分研究提出了无锁的跳表。基于 Java 的 ConcurrentSkipListMap 在实践中表现良好。经典的无锁跳表避免对多个 Writers 加锁。相反,ListDB 不会并发写入。唯一的 writers 是 Compaction 线程,ListDB 会协调这些线程避免写写冲突。对于并行性,多个压缩线程写入不相交的分片。一个分片是 L1 PMTable 中一个高度最大的元素到下一个高度最大的元素之间的一个不相交的键范围。为了将 L0 元素合并到 L1 中,压缩线程必须获得对应分片上的锁。</li>
<li>Zipper Compaction 分为两阶段:
<ul>
<li>从头到尾的 scan</li>
<li>从尾到头的 merge</li>
</ul>
</li>
<li>为了保证正确的搜索结果而不阻塞并发读,L0 PMTable 元素由尾到头被合并到 L1 PMTable 中,同时并发读取操作从头到尾遍历它们。</li>
</ul>
<h3 id="scan-phase">Scan Phase</h3>
<ul>
<li>在前向扫描阶段,压缩线程从头到尾遍历 L0 和 L1 PMTable,并确定每个 L0 PMTable 元素应该插入 L1 PMTable 中的什么位置。但是,在这个阶段,它不会对 PMTables 进行任何更改,而是将必要的指针更新推送到堆栈上。后向合并阶段弹出堆栈以应用并持久化 L1 PMTable 中的更新。</li>
<li>扫描阶段沿着 L0 PMTable 的底层。对于每个 L0 元素,它会搜索 L1 PMTable,以找到插入 L0 元素的位置。为此,它跟踪每层中小于当前搜索键 (L0元素) 的最右边的元素,以避免重复遍历 L1 PMTable。因为两个 PMTable 中的键都是排序的,所以 L0 PMTable 中的下一个较大的键可以重用前面最右边的元素,并回溯到最右边的最顶层元素进行下一次搜索。因此,扫描阶段的复杂度为 O(n0 + logn1),其中 n0 和 n1 分别为 L0 和 L1 PMTables 的大小。</li>
<li>对于支持 NUMA 的 Braided SkipList, Zipper 压缩需要一个最右的二维数组 [numa_id][layer] 来保持每一层中最右的元素数量与 Braided SkipList 的 NUMA 节点数量相同。但是,请注意,Braided SkipList 元素不需要比传统的 SkipList 元素更多的指针,因为它在 8 字节地址中嵌入了 NUMA 节点 ID。</li>
<li>下图 a 展示了一个例子:假设所有跳表元素都在一个 NUMA 节点
<ul>
<li>L0 的元素 A 需要放置到 L1 的第一个位置。因此 L1 头结点的 H0 和 H1 就是当前最右的指针,需要为 A 的合并来更新。压栈
<ul>
<li>A0 和 A1 需要指向 B。但是该信息没有压栈 ,因为 B 是由当前已经压入堆栈的最右边的元素(L1.H0, L1.H1)指向的。每个 L0 元素被插入到两个 L1 元素之间,每层中只有前一个(即最右边的)元素需要被推入堆栈,因为从前一个元素中可以找到下一个元素。</li>
</ul>
</li>
<li>接下来,扫描阶段访问 L0 中的第二个元素D,并搜索L1。插入D需要更新B2、C1和C0。同样,它们被压入堆栈。</li>
<li>最后,它访问L0中的最后一个元素E,并搜索L1。注意L1 PMTable没有改变,当前最右边的指针仍然是B2、C1和C0。因此,扫描阶段将C1和C0推入堆栈,使它们指向E。</li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="6"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%205.png" alt="Untitled" loading="lazy"></figure>
<h3 id="merge-phase">Merge Phase</h3>
<ul>
<li>合并阶段将指针更新从尾部应用到头部。当压缩线程从栈中弹出一个更新 <span class="katex"><span class="katex-mathml"><math><semantics><mrow><msub><mi>X</mi><mi>N</mi></msub></mrow><annotation encoding="application/x-tex">X_N</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.83333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathdefault" style="margin-right:0.07847em;">X</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.32833099999999993em;"><span style="top:-2.5500000000000003em;margin-left:-0.07847em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathdefault mtight" style="margin-right:0.10903em;">N</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span> → <span class="katex"><span class="katex-mathml"><math><semantics><mrow><mi>Y</mi></mrow><annotation encoding="application/x-tex">Y</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.68333em;vertical-align:0em;"></span><span class="mord mathdefault" style="margin-right:0.22222em;">Y</span></span></span></span> 的指针时,将 Y 元素中的第 N 层指针更新为 <span class="katex"><span class="katex-mathml"><math><semantics><mrow><msub><mi>X</mi><mi>N</mi></msub></mrow><annotation encoding="application/x-tex">X_N</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.83333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathdefault" style="margin-right:0.07847em;">X</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.32833099999999993em;"><span style="top:-2.5500000000000003em;margin-left:-0.07847em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathdefault mtight" style="margin-right:0.10903em;">N</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span> 的当前值,然后将<span class="katex"><span class="katex-mathml"><math><semantics><mrow><msub><mi>X</mi><mi>N</mi></msub></mrow><annotation encoding="application/x-tex">X_N</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.83333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathdefault" style="margin-right:0.07847em;">X</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.32833099999999993em;"><span style="top:-2.5500000000000003em;margin-left:-0.07847em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathdefault mtight" style="margin-right:0.10903em;">N</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span> 设置为 Y 的地址。</li>
<li>如上图所示:
<ul>
<li>Compaction 线程出栈 C0→E
<ul>
<li>图8b,因为 C0 当前指向了 F0,所以合并的时候就需要把 E0 指向 F0。但是 E1 这时候没有指向,但无伤大雅,不影响查询的正确性。</li>
<li>图8c,把 C0 指向 E0,好结束</li>
</ul>
</li>
<li>出栈 C1→E
<ul>
<li>图 8d,设置 E1 指向原来 C1 指向的位置 F1,然后让 C1 指向 E1</li>
</ul>
</li>
<li>后续操作同理</li>
</ul>
</li>
<li>Zipper 压缩假定 8 字节指针更新是原子的。为了保证指针更新 failure-atomic,它使用内存 fence 和 cacheline 刷新指令立即持久化每个底层更新。在最后一步中,压缩线程从 MANIFEST 对象中删除 L0 PMTable 的头元素,从而完成压缩。</li>
</ul>
<h3 id="lock-free-search">Lock-Free Search</h3>
<ul>
<li>Zipper 压缩不会违反并发搜索的正确性,也就是说,一个读线程不会在不获取锁的情况下错过它的目标 SkipList 元素。这是因为读线程从头到尾、从L0到L1访问PMTables,而压缩线程则从尾到头合并它们。在 Zipper 压缩期间,每个元素都保证是指向至少一个 head(<strong>即肯定是能追溯到的</strong>)。考虑图 8 所示的示例,它显示了一个原子存储指令序列如何合并两个示例 skiplist。即使并发读线程以图 8 所示的任何状态访问 PMTables,它也会返回正确的结果。</li>
<li>即使在压缩线程修改 SkipLists 的过程中读取线程挂起,该算法仍然是正确的。例如,假设读取在访问 L0 元素时挂起。当它恢复时,元素可能已经合并到 L1 中。当读线程醒来时,如果没有找到搜索键,它将继续遍历到尾部。一旦到达尾部,它就结束了 L0,并开始搜索 L1,L0 的元素已经合并到 L1 中。因此,读线程可能会多次访问相同的元素,但它永远不会错过它正在搜索的元素。多次访问可能会影响搜索性能。为了避免这种情况,如果读操作检测到当前元素的级别是 L1,它将停止搜索 L0。</li>
</ul>
<h3 id="updates-and-deletes">Updates and Deletes</h3>
<ul>
<li>LSM 树中的更新会重复相同的键,因为写操作会缓冲到 MemTables 中,并逐渐刷新到最后一层。<strong>ListDB 不会主动删除 L1 中的旧版本</strong>。相反,当压缩线程扫描 L0 和 L1 级别以进行 Zipper 压缩时,它将标记 L1 中的旧版本过时。类似地,ListDB 中的删除并不会物理地删除对象,而是将一个 key-delete 对象插入到 MemTable 中。如果 LSM 树从 MemTables 或 L0 PMTables 中物理删除密钥的最新版本,则旧版本的密钥将恢复使用。Zipper 压缩将较新的键值或键删除对象放在其对应的旧对象之前。因此,读查询总是在旧对象之前访问最近的对象,从而返回正确的搜索结果。</li>
</ul>
<h3 id="fragmentation-and-garbage-collection">Fragmentation and Garbage Collection</h3>
<ul>
<li>通过使用 libpmemobj库,ListDB为PMDK的故障原子事务中的多个 IUL 条目分配和释放一个内存块(例如,8 MB),从而可以减少对昂贵的PMDK事务的调用数量。如果一个内存块中的所有元素都被标记为过时或删除,则 ListDB 将释放该内存块。注意,ListDB 不会重新定位 SkipList 元素以进行垃圾收集。为了解决持久的碎片问题,压缩线程可以执行基于 CoW 的垃圾收集。作者把这个优化留给以后的工作。</li>
<li>无锁数据结构的内存管理是一个困难的问题,因为没有简单的方法来检测被释放的内存空间是否仍然被并发读取访问。ListDB 使用简单的基于 epoch 的回收;ListDB 不会立即释放内存块,但会等待足够长的时间,让短期的读查询完成对释放内存块的访问。如果内存块中的所有对象都被废弃或删除,后台垃圾收集线程会定期检查并回收内存块。对于过时的对象,垃圾收集线程检查其新版本的 LSN。如果它也足够老,它认为过时的对象没有被任何读取访问,从 L1 PMTable 中删除它们,并物理地释放内存块。</li>
</ul>
<h2 id="look-up-cache">Look-up Cache</h2>
<ul>
<li>ListDB 要求读查询至少访问两个索引,即一个可变的 MemTable 和 L1 PMTable。因此,ListDB 的读吞吐量明显低于高度优化的持久 B+ 树。</li>
<li>ListDB 引入了查询缓存,Flush MemTable 将每个元素散列到一个固定大小的静态散列表中。与基于磁盘的设计不同,查找缓存不会复制其中的元素,而只存储它的 NVMM 地址,因为 NVMM 中的元素已经在内存地址空间中,而且它的地址永远不会改变。因此,无论 PMTable 元素出现在哪个级别,查找缓存都可以定位到 PMTable 元素。ListDB 中的压缩线程经常更新 SkipList 指针,但是通过缓存不可变的地址,而不是可变的内容,查找缓存可以避免频繁的缓存失效。如果一个桶发生哈希冲突,旧的地址将被覆盖(即FIFO替换策略)。</li>
<li>ListDB 在 DRAM 中构造一个SkipList,作为从哈希表中移除的高元素的二级查找缓存。第二级查找缓存的目的是加速 PMTable 搜索。即使在第二级查找缓存中没有找到一个键,查询也可以从缓存中找到的最近的 PMTable 元素开始搜索。假设读操作查找键 100,但是发现元素 85 是 L1 中最近的更小的元素。然后,从 L1 PMTable 中的 85 号元素继续搜索,而不是 L1 的开头。ListDB 不使用第二级查找缓存进行 L0 搜索,因为小的 L0 PMTables 很快被合并到 L1 中,L0元素大部分缓存在查找哈希表中,第二级查找缓存使用 SIZE 替换策略[58],即比较高度并剔除高度较短的元素。</li>
</ul>
<h2 id="recovery">Recovery</h2>
<ul>
<li>当 L0 和 L1 PMTables 被 Zipper 压缩合并时,系统可能会崩溃。为了从这样的故障中恢复,压缩线程执行微日志记录来跟踪哪个 L0 PMTable 被合并到L1 PMTable中。当系统重新启动时,ListDB 检查压缩日志以重做未完成的压缩。对于重做操作,由于 L0 PMTable 尾部的许多条目可以与 L1 PMTable 共享,因此 Zipper compaction 需要检查重复的条目。</li>
<li>ListDB的恢复算法,与传统LSM树的恢复算法类似。首先,恢复进程定位WAL的边界,该边界由压缩线程记录在压缩日志中。然后,它对日志条目进行排序并恢复L0 PMTables。此时,系统返回到正常执行模式并开始处理客户端查询。L0和L1之间的压缩将在后台正常进行。</li>
<li>对于查找缓存,ListDB 可以在不恢复缓存的情况下处理客户端查询,尽管在填充缓存之前搜索性能会很差。通过避免 DRAM 缓存和索引的重构,ListDB的恢复性能优于同步检查点</li>
</ul>
<h1 id="evaluation">Evaluation</h1>
<h2 id="测试-index-unified-logging">测试 Index-Unified Logging</h2>
<h3 id="flush-and-put">flush and put</h3>
<ul>
<li>关闭 Zipper Compaction,WAL 中 put 比 WAL 好因为内存写入比 内存拷贝到 NVMM 更快,put 的性能抖动尖刺代表的就是新的空的 Memtable 创建储来,5s 填充一个 Memtable,40s 达到 Memtable 的阈值限制,阻塞后续写。如果提高阈值,stall 也只是时间长短的问题,因为 flush 比写慢。</li>
<li>IUL 的 flush 比 写快,但是 flush 波动,每个 flush 花的时间比 put 少,但是 compaction 线程可能挂起空闲了,所以波动。flush 吞吐高因为 IUL 不从 DRAM 拷贝数据到 NVM,不调用 cacheline flush 指令,因此,写入停顿不会发生,压缩线程通常会变得空闲,从而允许 CPU 执行其他工作。</li>
</ul>
<figure data-type="image" tabindex="7"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%206.png" alt="Untitled" loading="lazy"></figure>
<h3 id="ycsb">YCSB</h3>
<ul>
<li>不同线程,后台 Compaction 线程设置为前台客户端现成的一半,开启了 Zipper Compaction。</li>
<li>80线程之前,两种 log 方式性能都有提升,但是,当客户机线程的数量超过逻辑核心的数量时,由于过高的超额使用速率,吞吐量会下降。也就是说,100 个客户机线程和 50 个后台压缩线程竞争 80 个逻辑核。然而,IUL 的吞吐量比WAL高 99%。</li>
<li>YCSB-B C D 两种方式性能差不多,有时候甚至WAL更好,因为WAL执行copy-on-write 操作以按键升序存储记录,而读操作得益于比 IUL 更高的内存访问局部性。但在工作负载 A (50:50 Read:Write) 中,IUL 的写性能优于 WAL。</li>
</ul>
<figure data-type="image" tabindex="8"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%207.png" alt="Untitled" loading="lazy"></figure>
<h2 id="测试-braided-skiplist">测试 Braided SkipList</h2>
<ul>
<li>对比 不考虑 NUMA 的 Obl, Delegating 方案 Deleg,写优化的本地跳表 Local。</li>
<li>BR 和 Obl 管理一个大的 PMTable,而 Deleg 和 Local 创建四个小的PMTable。根据散列键删除分区键值记录,但是 Local 允许写客户端在其本地 NUMA节点上插入数据到 SkipList,而不考虑键。因此,一个读查询必须搜索所有四个 skiplist。即使在本地索引中找到了一个键,它也必须搜索远程索引,因为远程索引可能有最近的更新。因此,当有 n 个NUMA节点时,本地访问的比例始终为 1/n。</li>
<li>写操作 Local 最好,因为总是写入到本地跳表,几乎消除了远程 NUMA 访问。BR 差一点,因为访问到底层的数据指针的时候需要访问远端节点,Deleg也完全删除了远程NVMM访问,但是由于委托开销,写响应时间要高得多。也就是说,线程使用缓慢的原子指令来访问共享队列,并为查询和结果复制内存。图11(b)显示,队列延迟占80个客户机线程的查询响应时间的77.1%。因为在无锁索引上的 put/get 操作是非常轻量级的,委托引起的同步开销占据了总体响应时间。</li>
<li>读操作:图12(a)显示BR对于读查询的响应时间比其他方法要低。虽然Local在写方面优于BR,但Local的读响应时间大约是BR的4倍,因为Local必须搜索所有4个pmtable。尽管BR避免了访问跟随远程元素的更有效的搜索路径,但图11(a)和12(a)表明它对遍历长度几乎没有影响。Deleg显示最少的内存访问。但是由于同步开销,它的查询响应时间比BR高出约2倍,性能甚至低于Obl。</li>
</ul>
<figure data-type="image" tabindex="9"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%208.png" alt="Untitled" loading="lazy"></figure>
<h2 id="整个系统测试">整个系统测试</h2>
<ul>
<li>图13给出了ListDB的因子分析。我们启用和禁用ListDB的每个设计特性,并随着时间的推移测量写吞吐量(表示为put)、刷新吞吐量(MemTable L0 → PMTable,表示为 flush)和Compaction吞吐量(L0→L1 PMTable,表示comp)。我们为YCSB Load A运行了80个客户机线程和40个后台压缩线程,插入5亿个8字节的键和8字节的值。</li>
<li>图13(a)显示禁用所有三种优化会导致客户端线程暂停超过50秒。如图13(b)所示,启用Zipper压缩可以提高 L0→L1压缩吞吐量,但是由于刷新MemTable的内存拷贝开销,仍然会出现写停顿问题。如果使用了Braided SkipList,则可以避免在刷新MemTable时访问远程NUMA节点。因此,刷新吞吐量增加了一倍,从而降低了写停顿的频率,如图13(c)所示。同时启用Zipper压缩和Braided SkipList可以缩短写暂停时间,工作负载在120秒内完成(图13(d))</li>
<li>如果使用IUL而不是WAL,则刷新吞吐量与 PUT 吞吐量相当,如图13(e)所示。通过避免昂贵的内存拷贝,写停顿的频率比WAL要低。但是,请注意,压缩吞吐量比刷新吞吐量低得多。这会增加L0 PMTables的数量,降低搜索性能。如图13(f)所示,如果另外启用了IUL和Zipper压缩,那么NVMM带宽会通过减少内存副本的数量来提高。因此,它提高了压缩和刷新吞吐量。如图13(g)所示,启用IUL和Braided SkipList可以避免NUMA效应,从而提高压缩和刷新吞吐量。最后,启用所有三个优化后,工作负载在65秒内完成,几乎没有写入停顿(图13(h)),而图13(a)中的时间为300秒。</li>
</ul>
<figure data-type="image" tabindex="10"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%209.png" alt="Untitled" loading="lazy"></figure>
<h2 id="故障恢复">故障恢复</h2>
<ul>
<li>我们评估了针对ListDB的异步增量检查点和周期性同步检查点的恢复性能。使用Facebook基准测试,我们用1亿个对象填充数据库,并使用检查点和提前写日志条目测量恢复的时间。尽管使用相同的工作负载,同步检查点的恢复性能受检查点间隔的影响,而异步检查点只受查询到达率的影响。这是因为日志条目的数量随异步检查点而变化,而后台压缩线程还没有合并到 L1中。如果查询到达率高于 Zipper 压缩吞吐量,IUL 条目的数量会增加,恢复过程必须创建一个更大的 L0 PMTable,其中包含更多的日志条目。</li>
<li>图14显示了一个同步检查点使用Boost库中的 binary_oarchive 类序列化和刷新内存中的B+树大约需要90秒。这将导致并发查询在检查点执行期间阻塞90秒,导致不可接受的高尾延迟。为了缓解这个问题,可以降低检查点的执行频率,但是随着日志条目的增加,恢复时间会增加(即,恢复检查点索引并向其插入日志条目的时间)。</li>
<li>相反,图14 (b)显示,如果写查询到达率低于300万次/秒,ListDB就会立即恢复。如果查询到达率在700万到900万插入/秒之间变化,ListDB需要大约19秒来恢复。查询到达率越高,ListDB的恢复时间就越长。</li>
</ul>
<figure data-type="image" tabindex="11"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%2010.png" alt="Untitled" loading="lazy"></figure>
<h2 id="对比其他设计">对比其他设计</h2>
<ul>
<li>图15所示的实验比较了ListDB与最先进的持久索引的性能;即BzTree [1], FP-tree [49], FAST and FAIR B+tree[24],和PACTree[32],我们在一个双套接字机器上运行实验,因为PACTree是硬编码的两个套接字。采用Intel Xeon Gold 5215处理器(共40个逻辑核)、128gb DRAM (8 * 16GB)、1tb DCPMM (8 * 128gb)。数据库预加载了1亿条键值记录,然后40个客户端提交1000万个查询,这些查询具有不同的读写比率,分布均匀(由YCSB Workload A生成)。这些树结构索引没有针对(或不支持)大的可变长度字符串键和值进行优化。因此,我们为工作负载生成了8字节的数字键和8字节的指针值,这有利于具有较大扇出的树结构索引。</li>
<li>图 15 显示,对于写密集型工作负载,ListDB的性能优于树状结构的持久索引。对于只写工作负载,ListDB(0GB)的吞吐量分别比BzTree、FPTree、FAST和FAIR B+tree和PACTree高79倍、17倍、2.3倍和1.6倍。但是,对于只读工作负载,树结构索引受益于更快的搜索性能。特别是,FAST和FAIR B+树和PACTree显示的搜索吞吐量分别比ListDB(0GB)高3.88倍和4.61倍。在启用了查找缓存后,ListDB的性能优于树状结构的索引。图键中圆括号中的数字显示了查找缓存的大小。如果查找缓存大于768 MB, ListDB的性能优于PACTree,除非读比率高于80%。</li>
<li>这些结果证实了标准缓存技术可以很容易地提高读取性能。但是,索引键值记录位置的查找缓存不能用于PACTree、FAST FAIR B+tree、FPTree等,因为它们经常会因为树的再平衡操作而将键值记录重新定位到不同的树节点上。也就是说,为树状结构的持久索引使用DRAM缓存不像我们的仅地址查找缓存那么简单。例如,Nap[57]具有非常复杂的缓存机制。</li>
<li>虽然LSM树比树结构索引有更好的写性能,但它们有更高的写放大,这是块设备存储的一个关键限制[21,41,53]。为了比较写入放大,我们使用Intel PMwatch[25]来测量在图15所示的实验中访问的总字节数。所有的索引方法都存在写放大的问题。DCPMM的内部写合并缓冲区将一个小的写操作(8字节的键和8字节的值)转换为256字节的读-修改-写操作,导致至少16倍的写放大。在ListDB中,L0和L1 PMTables中的归并排序操作进一步放大了写操作。然而,ListDB(104.4)的写扩增比FAST和FAIR B+tree(126.789)低,与PACTree(91.5)相当,因为ListDB会对SkipLists进行合并排序。</li>
<li>图16显示了NoveLSM、SLM-DB、Pmem-RocksDB和ListDB的单线程读写吞吐量。这些实验运行一个单客户机线程(db_bench, 1亿个随机的8字节键和1 KB值),因为当多个线程并发访问数据库时,NoveLSM会崩溃。NoveLSM和SLM-DB被设计成使用NVMM作为块设备文件系统之上的中间层,但是我们的实验将所有sstable存储在使用EXT4-DAX格式化的NVMM中,以便进行公平的比较。</li>
<li>NoveLSM表现出最差的性能,不是因为它的设计,而是因为它是在LevelDB之上实现的,众所周知,LevelDB的性能很差。SLM-DB也是在LevelDB之上实现的,但是性能更好,因为它使用FAST和FAIR B+树作为核心索引。由于SLM-DB还没有移植到使用PMDK,所以它没有运行时刷新或事务更新带来的开销,也就是说,它显示DRAM性能,并且不会在系统崩溃时存活下来。尽管如此,SLM-DB的性能并不比完全持久的键值存储Pmem-RocksDB更好。与Pmem-RocksDB相比,ListDB(0GB)的写吞吐量是Pmem-RocksDB的两倍,但读性能略差,除非启用了查找缓存。这是因为Pmem-RocksDB得益于内存位置,它以有序的顺序连续地将键存储在NVMM中,而ListDB不重新定位数据</li>
</ul>
<figure data-type="image" tabindex="12"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%2011.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>最后,我们在Facebook基准测试中使用Prefix Dist工作负载,比较ListDB和Intel的Pmem-RocksDB的性能。图17所示的实验运行了80个客户机线程,并使用基准的默认键和值大小(48字节字符串键和16字节到10 KB不等的可变长度值)。工作负载根据查询到达率(QPS参数)提交查询,该查询到达率遵循噪声因子为0.5的正弦分布。工作负载的put/get比率为3比7。</li>
<li>最后,我们在Facebook基准测试中使用Prefix Dist工作负载,比较ListDB和Intel的Pmem-RocksDB的性能。图17所示的实验运行了80个客户机线程,并使用基准的默认键和值大小(48字节字符串键和16字节到10 KB不等的可变长度值)。工作负载根据查询到达率(QPS参数)提交查询,该查询到达率遵循噪声因子为0.5的正弦分布。工作负载的put/get比率为3比7。</li>
<li>对于空闲工作负载,Pmem-RocksDB受到过多NVMM写入的影响,因此它的put吞吐量在200 Kops时饱和。对于Facebook基准测试,get查询必须等待它的前一个put查询提交。因此,实验中Pmem-RocksDB的get吞吐量在400 Kops处饱和。相反,图17(b)显示ListDB的吞吐量遵循正弦分布,即查询到达率,没有阻塞查询。</li>
<li>对于高负载,Pmem-RocksDB的吞吐量仍然处于饱和状态。另一方面,ListDB的put吞吐量比Pmem-RocksDB高25倍,即500万次。同样,ListDB的get吞吐量比Pmem-RocksDB高22倍(即1300万vs 60万)。因此,ListDB完成工作负载的速度比Pmem-RocksDB快19.4倍(即380比7400秒)。</li>
</ul>
<figure data-type="image" tabindex="13"><img src="https://blog.shunzi.tech/post-images/ListDB//Untitled%2012.png" alt="Untitled" loading="lazy"></figure>
<h1 id="conclusion">Conclusion</h1>
<ul>
<li>在这项工作中,我们设计并实现了ListDB——一个利用字节寻址的键值存储,通过就地重构数据来避免数据复制,以及NVMM的高性能来减少写放大和避免写停滞。我们展示了ListDB通过异步增量检查点和就地压缩显著提高了写性能。通过它的三层结构,ListDB在写吞吐量方面优于最先进的持久索引和基于nvmm的键值存储。标准的查找缓存可以帮助缓解具有多个级别的问题。在未来的工作中,我们正在探索通过引入另一个层次,即L2 PMTable来提高搜索性能的可能性,以机会主义地重新排列L1 PMTable元素,用于空间局部性和垃圾收集</li>
</ul>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[p2KVS: a Portable 2-Dimensional Parallelizing Framework to Improve Scalability of Key-value Stores on SSDs]]></title>
<id>https://blog.shunzi.tech/post/p2KVS/</id>
<link href="https://blog.shunzi.tech/post/p2KVS/">
</link>
<updated>2022-07-12T03:40:00.000Z</updated>
<content type="html"><![CDATA[<blockquote>
<ul>
<li>p2KVS: a Portable 2-Dimensional Parallelizing Framework to Improve Scalability of Key-value Stores on SSDs</li>
</ul>
</blockquote>
<h1 id="p2kvs-a-portable-2-dimensional-parallelizing-framework-to-improve-scalability-of-key-value-stores-on-ssds">p2KVS: a Portable 2-Dimensional Parallelizing Framework to Improve Scalability of Key-value Stores on SSDs</h1>
<h1 id="abstract">Abstract</h1>
<ul>
<li>通过将速度较慢的硬盘驱动器(hdd)替换为速度更快的固态硬盘驱动器(ssd)来提高键值存储(KVS)性能的尝试一直未能达到ssd和hdd之间的巨大速度差距所暗示的性能提升,特别是对于小KV项目。我们通过实验和整体探索了现有基于lsm树的 kvs 运行在具有多核处理器和快速ssd的强大现代硬件上性能低下的根本原因。我们的发现表明,在单线程和多线程执行环境下,全局预写日志(WAL)和索引更新(MemTable)可能成为与常见的lsm树压缩瓶颈一样基本和严重的瓶颈</li>
<li>为了充分利用成熟KVS和底层高性能硬件的性能潜力,我们提出了一种可移植的二维KVS并行化框架,称为p2KVS。在水平的kvs -instance维度上,p2KVS将一个全局KV空间划分为一组独立的子空间,每个子空间由一个LSM-tree实例和一个固定在一个专用核上的专用工作线程来维护,从而消除了共享数据结构上的结构性竞争。在垂直的intra-KVS-instance维度上,p2KVS将用户线程从kvs -worker中分离出来,并在每个worker上提供基于运行时队列的机会批处理机制,从而提高了进程效率。由于p2KVS被设计和实现为一个用户空间请求调度器,将WAL、MemTables和lsm树作为黑盒来查看,因此它是非侵入性的,具有高度的可移植性。在微观和宏观基准测试下,p2KVS比最先进的RocksDB提高了4.6×写和5.4×读的速度。</li>
</ul>
<h1 id="introduction">Introduction</h1>
<h2 id="动机测试">动机测试</h2>
<ul>
<li>SSD 相比于 HDD 有更高的 IO 带宽 10x,更高的 IOPS x100。图1a表明,尽管RocksDB在高级 SSD 上的读性能提高了2个数量级,但它在ssd和hdd之间的<strong>写性能几乎没有变化</strong>。</li>
<li>最近的研究[19,36]和我们的实验结果一致表明,<strong>较小KV对的写工作负载会使系统的主机 CPU 核过载,而不是受到系统IO带宽的瓶颈</strong>。</li>
</ul>
<figure data-type="image" tabindex="1"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>然而,提高处理能力的一种幼稚的方法是通过调用更多的用户线程来充分利用多核CPU的能力。图1b 显示,即使有8个用户线程,对于顺序PUT、随机PUT和UPDATE的写工作负载,每秒查询(QPS)性能也只分别提高了40%、150%和160%,远远没有达到理想的线性扩展。但是,与单线程情况相比,在写工作负载下,从基于hdd的系统到基于ssd的系统,三个8线程情况的性能提高不到10%,除了更新操作情况提高了40%。这意味着,RocksDB的设计初衷是充分利用优质SSD,最大限度地提高QPS[22],但在小型KV工作负载下仍然存在瓶颈。</li>
</ul>
<figure data-type="image" tabindex="2"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%201.png" alt="Untitled" loading="lazy"></figure>
<h2 id="相关工作">相关工作</h2>
<ul>
<li>以前的研究表明潜在的瓶颈主要表现在 logging (SIGMOD’18 FASTER),索引(FloDB)以及 Compaction(SILK,LDC,WiscKey)阶段。</li>
<li>大量的现有 KVs 研究
<ul>
<li>提出了新的全局数据结构来代替 LSM(SplinterDB,SLM-DB,KVell)</li>
<li>提出了局部优化
<ul>
<li>缓存:LightKV,SplitKV,NoveLSM,SCM LSM,MatrixKV</li>
<li>日志:FASTER,SpanDB</li>
<li>并发索引:No Hot Spot Non-blocking Skip List,Asynchronized Concurrency,UniKV:</li>
<li>Compaction:PebblesDB,LSM-trie</li>
</ul>
</li>
<li>工业界也有利用硬件优势来进行优化,比如批量写入,并发跳表,多线程压缩</li>
<li>带有多个实例的KVS分片机制广泛用于公共数据库实践,以利用实例间的并行性
<ul>
<li>HBase</li>
<li>The RocksDB Experience</li>
<li>Column Families of RocksDB</li>
<li>RocksDB tuning guide</li>
<li>Nova-LSM</li>
<li>DocDB</li>
</ul>
</li>
</ul>
</li>
<li><strong>现有的工作不能全面有效地解释和解决高性能硬件的性能可扩展性差。</strong></li>
</ul>
<h2 id="理论分析">理论分析</h2>
<ul>
<li>前台任务首先写 WAL,然后执行写索引,然后后台任务执行 compaction。</li>
<li>为了帮助理解成熟的kvs在快速的ssd和强大的处理器上性能低下的根本原因,我们使用经过良好优化的RocksDB进行了一系列实验(详见第3节)。结果提供了三个揭示性的发现。
<ul>
<li>首先,对于单个用户线程,日志记录或索引都可能造成严重的计算瓶颈,严重限制随机的小型写操作的性能。只有当日志记录和索引不再是瓶颈时,LSM-tree压缩才会成为存储瓶颈。</li>
<li>其次,增加用户线程的数量会产生一些边际效益,因为共享日志和索引结构上的争用非常多,用户线程越多,争用就越严重。</li>
<li>第三,具有多个实例的KVS分片机制仍然受到多个用户线程争用的影响,以及低效的日志记录和索引</li>
</ul>
</li>
</ul>
<h2 id="本文贡献">本文贡献</h2>
<ul>
<li>为了克服现有 KVs 的缺陷,提出了可移植的二维并行 KVS 框架来高效利用成熟的生产环境中的 KVs 实现和底层的高性能硬件。
<ul>
<li>首先,水平维度,采用了一个调度方案来协调多个工作线程,每个线程专门绑定核心,维护自己的单独的 WAL 日志,Memtable,以及 LSM tree,来减小共享数据结构上的争用</li>
<li>其次,垂直层面,设计了一个全局的 KV 访问层来把用户线程和 KVs 工作线程区分开,访问层应用策略把进来的请求分发到工作队列中,进行负载均衡</li>
<li>第三,在每个worker中提出了一种基于运行时队列的机会批处理机制。对于队列中未完成的写请求,OBM 合并它们以分摊 kv 处理和日志记录的开销。对于未完成的读请求,OBM 调用现有的 multiget 功能,提高处理效率。</li>
</ul>
</li>
<li>与多实例KVS配置不同,p2KVS显式地消除了实例上用户线程之间的潜在争用,同时利用批处理机制来提高效率</li>
<li>本文的目标是构建一个在 RocksDB 上的基于线程的并行框架,充分利用硬件特征同时保留它们现有的功能和内部设计,因此本文的方案是互不干扰的且非侵入式的。</li>
<li>贡献
<ul>
<li>我们通过实验和整体分析并确定了运行在快速ssd和多核处理器上的kvs伸缩性差的根本原因。</li>
<li>我们提出了p2KVS,一个可移植的二维KVS并行化框架,以统一有效地利用KVS实例之间和实例内部的内部并行性。我们进一步设计了一个基于队列的机会批处理机制,以提高每个工人的处理效率。</li>
<li>我们分别在RocksDB、LevelDB和WiredTiger的[49]上实现了p2KVS原型,并在流行的宏基准和微基准下进行了大量的实验来评估它。与目前最先进的基于lsm树的RocksDB和PebblesDB相比,p2KVS对小型kv的写速度可达4.6x,读速度可达5.4x。它还优于最先进的基于非lsm树的KVell。</li>
</ul>
</li>
</ul>
<h1 id="background">Background</h1>
<ul>
<li>LSM 不做介绍</li>
</ul>
<h2 id="rocksdb-并发优化">RocksDB 并发优化</h2>
<ul>
<li>作为一个经过良好优化的基于生产级 LSM 树的KVS, RocksDB 通过利用硬件并行性实现了许多优化和配置,以提高其QPS性能。RocksDB中的并发写进程示例如图 3 所示。关键并发性优化如下:</li>
</ul>
<h3 id="group-logging">Group Logging</h3>
<ul>
<li>当多个用户线程并发提交了写请求,RocksDB 将其组织成为一个组,其中一个线程被选中作为 leader 负责聚合该组内的其他线程的日志项,然后一次写入日志文件,其他线程也就是所谓的 follower,挂起直到日志文件写入完成,减少了实际的日志 I/O,因此提升了 I/O 效率。</li>
</ul>
<figure data-type="image" tabindex="3"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%202.png" alt="Untitled" loading="lazy"></figure>
<h3 id="并发-memtable">并发 Memtable</h3>
<ul>
<li>RocksDB 支持并发跳表索引,提升 Memtable 的插入的 QPS 约 2x,像 Group Logging,在更新全局元数据时,会同步一组并发更新MemTable的用户线程</li>
</ul>
<h3 id="pipelined-write">Pipelined Write</h3>
<ul>
<li>RocksDB 把不同组的日志和索引更新步骤流水线化了来减小阻塞</li>
<li>基于 LSM 的 KVs 一般提供了请求批处理操作,也就是 WriteBatch,允许用户执行多个写类型的请求,RocksDB 将所有请求的日志记录操作合并到一个WriteBatch中,就像组日志记录机制一样。</li>
</ul>
<h1 id="root-causes-of-poor-scalability">Root Causes of Poor Scalability</h1>
<h2 id="单线程">单线程</h2>
<ul>
<li>五组实验,128B KV,10M 条,顺序和随机写,随机 UPDATE,顺序和随机读,分别在 HDD/SATA SSD/NVMe SSD 上测试。</li>
<li>写性能没变化,读性能倒是有很大提升
<ul>
<li><strong>单线程写性能表现较差,读性能较好</strong></li>
<li><strong>8 线程写性能表现类似</strong></li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="4"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%203.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>
<p>下图展示了一个用户线程插入小 KV 对应的随时间变化的性能,顺序和随机。CPU-core 利用率为 100% 的线程分别只占用 SSD IO 全带宽的 1/6 和 1/20。连续的随机写操作会触发相应的后台线程执行周期性的刷新和压缩,这些线程会消耗大约 25% 的 CPU-core 利用率。</p>
<ul>
<li>现象:<strong>无论是随机写还是顺序写,无论是小 KV 还是大 KV,都远低于 SSD 的实际带宽</strong>
<ul>
<li>顺序写的带宽低于随机写,小KV 的带宽低于大 KV</li>
<li>随机写 大 KV 的吞吐相对最接近设备带宽</li>
</ul>
</li>
<li><strong>原因</strong>:<strong>无论是随机写还是顺序写,无论是小 KV 还是大 KV,前台线程 CPU 都接近满载</strong>
<ul>
<li>除了大 KV 随机写以外,CPU 基本都是满载 ****</li>
</ul>
</li>
<li>现象:<strong>随机写因为受到 Compaction 的影响会有较大的性能波动</strong>
<ul>
<li>小 KV 随机写,后台线程大约需要使用 25% 的 CPU,前台线程 100%</li>
<li>大 KV 随机写,后台线程大约需要使用 60% 的 CPU,前台线程 70%</li>
</ul>
</li>
<li>现象:<strong>大 KV 相比于小 KV 性能表现相对更好</strong>
<ul>
<li>原因:小 KV 的 CPU 满载的现象更严重,因为常常小 KV 意味着更多的数据条数,软件栈上的执行的请求数更多</li>
</ul>
</li>
</ul>
</li>
<li>
<p>当 CPU-core 利用率约70%的用户线程连续插入随机大KV对(即1KB)时,后台线程的周期性 compaction 操作仅消耗23% IO带宽和60% CPU-core利用率,如图4b所示。</p>
</li>
<li>
<p>以往的研究大多认为LSMtree压缩是严重影响整体性能的主要因素,因为LSMtree压缩具有写停顿和写放大的高IO强度[1,3,18,41,43,46,50,54]。<strong>事实上,使用小型KV对的写工作负载会使 CPU 核过载,但IO带宽利用不足,而使用大型KV对的写工作负载则恰恰相反</strong></p>
<ul>
<li>ForestDB,TRIAD,Dostoevsky,WiscKey,SifrDB,PebblesDB,LSM-trie,MatrixKV</li>
</ul>
<figure data-type="image" tabindex="5"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%204.png" alt="Untitled" loading="lazy"></figure>
</li>
</ul>
<h2 id="多线程">多线程</h2>
<ul>
<li>多核处理器和基于NVMe的ssd分别具有足够的计算能力和高并行度的IO能力。自然,利用强大的硬件增加用户线程的数量可以提高KVS的总体吞吐量。另外,在实践中,数据库从业者也可以简单地在高级硬件上配置多个独立的KVS实例,以提高整体性能。在此分析中,我们考虑由多个用户线程访问的单实例和多实例两种情况。每个KVS实例都有自己独立的日志文件、MemTable和LSM-tree。</li>
<li>下图 a 所示为扩展性,在所有并行优化的单实例情况下,写QPS的伸缩性仍然很差,在32个用户线程时只能获得微薄的 3x加速。它的吞吐量在24个线程时达到峰值,而在此之后进一步扩展则显示收益递减。与单实例情况相比,多实例情况提升80%的高吞吐量和更好的可伸缩性,其吞吐量峰值低于16个线程/实例。
<ul>
<li>现象:<strong>单实例,24线程达到峰值,绑核之后效果更好,多实例提升明显,且伸缩性更好,但 16 线程达到峰值</strong>
<ul>
<li><strong>结论:使用多实例带来的扩展性更好,绑核也能让扩展性变好</strong></li>
</ul>
</li>
</ul>
</li>
<li>图 b 则表明 compaction 不再是瓶颈。至少不是主要的瓶颈,即便是多线程情况下。在 16 线程时达到峰值,只有 1/5 的 SSD I/O 带宽,其中 compaction 占据的带宽也不足总的 3/4。和单用户线程类似,前台用户线程的利用率接近 100%,后台线程消耗的 CPU 利用率相对较低。
<ul>
<li>现象:<strong>单实例,多线程写操作的 I/O 带宽远小于实际的设备带宽,其中 Compaction 开销很小,不是主要开销。</strong>
<ul>
<li><strong>结论:随着并发增加,Compaction 占据的开销比例变化不大,即并发条件下,Compaction 不是瓶颈</strong></li>
</ul>
</li>
<li>现象:单实例,多线程写操作对应的前台线程 CPU 满载,后台线程随着并发数的增加 CPU 开销变大,但是也还没有到 CPU 极限。
<ul>
<li><strong>结论:随着并发增加,主要瓶颈是来自于前台线程的 CPU 满载</strong></li>
</ul>
</li>
</ul>
</li>
<li>图 a 还表明了绑核能带来 10%-15% 的性能收益,因为不需要切换 CPU</li>
</ul>
<figure data-type="image" tabindex="6"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%205.png" alt="Untitled" loading="lazy"></figure>
<h2 id="处理时间">处理时间</h2>
<ul>
<li>
<p>下图展示了不同线程在单个实例下的延迟分解,写流程分成五个步骤,WAL,Memtable,WAL Lock,Mmetable Lock and Others.</p>
<ul>
<li>WAL 代表写前日志的执行时间,包括 I/O 时间和其他(编码日志记录,计算校验和,以及添加到写日志的 memory buffer)</li>
<li>Memtable 代表插入键值对到 Memtable,超过 90% 都是更新跳表索引信息</li>
<li>WAL Lock 表示与组日志机制相关的锁开销,包括领导线程执行 WAL 时其他用户线程的锁获取时间,以及领导线程通知其他线程完成 WAL 执行的时间。</li>
<li>Memtable Lock 代表同一组线程并发写 MemTable 时的线程同步时间</li>
<li>其他代表其他软件开销</li>
</ul>
</li>
<li>
<p>锁开销:随着写入器数量的增加,WAL 和 MemTable 的 CPU 周期百分比从单个线程时的 90% 下降到 32 个线程时的16.3%,而总锁开销(即WAL锁和MemTable 锁)从几乎为零增加到 81.4%。更多的写入器会对共享数据结构(如日志和MemTable)引入更严重的争用。特别是,只有 8 个线程的 WAL-lock 占用了一半以上的延迟。<strong>根据 Amdahl 定律,在小型KV对的高并发工作负载下,对特定日志和索引结构的优化不再有效,因为序列化瓶颈占延迟的较大比例</strong>。</p>
</li>
<li>
<p>造成高锁开销的主要原因有三个。首先,</p>
<ul>
<li>RocksDB 的组日志策略由 leader 进行日志写入序列化的操作,并挂起follower;</li>
<li>第二,组中的线程越多,用于解锁 follwer 线程的 CPU 时间就越多;</li>
<li>最后,将多个线程插入到MemTable中会带来同步开销。</li>
</ul>
</li>
<li>
<p>现象:<strong>单实例,随着线程数增加,延迟也不断增加,其中加锁操作带来的开销比例也不断增加</strong></p>
<ul>
<li><strong>原因</strong>:<strong>因为涉及到跳表并发写的同步操作,以及 Group Logging 的同步操作,线程数增加,争用越大,延迟越高</strong></li>
</ul>
<figure data-type="image" tabindex="7"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%206.png" alt="Untitled" loading="lazy"></figure>
</li>
</ul>
<h2 id="multi-threading-a-double-edged-sword">Multi-threading, a Double-edged Sword</h2>
<ul>
<li>如上所示的实验结果表明,多线程在利用并行性方面的优势可以被线程之间在共享数据结构(如日志和索引)上的争用所抵消。因此,应该找到一个谨慎的权衡,以达到最佳的整体结果。考虑到这一点,接下来,我们将研究多线程对日志记录和索引这两个关键瓶颈的影响。我们实验分析了128字节KV工作负载下WAL进程单实例和MemTable进程多实例的性能,如图8所示</li>
</ul>
<h3 id="wal">WAL</h3>
<ul>
<li>如图6所示, WAL 的平均延迟降低从一个用户线程 2.1微妙到在 32 个用户线程 0.8 微妙。这是因为分组日志策略将来自不同线程的小日志收集到更大的IOs中,从而提高了IO效率。</li>
<li>为了演示批处理机制对写性能的影响,我们在将几个128字节的键值对批处理为 256 字节到 16KB 大小的WriteBatch请求时,测量了WAL的带宽和CPU使用情况,如图7所示。<strong>在RocksDB的默认配置中,启用了RocksDB的异步日志记录方式,以消除每次小IO后由于fsync引起的写放大</strong>。结果表明,<strong>请求级批处理机制不仅可以通过IO大小提高SSD的带宽利用率,还可以通过IO栈中软件开销的减少有效降低CPU负载</strong></li>
<li>现象:<strong>更大的 writebatch,更高的 I/O 带宽利用,更高的性能,更低的 CPU 利用率</strong>
<ul>
<li><strong>原因:因为更大的 batch,节省更多的小 I/O 造成的写放大,因为 I/O 更大,也减小了 I/O 栈上的软件开销,CPU 开销也就降低</strong></li>
<li><strong>结论:使用 Batch 机制有利于改进性能,并提升 I/O 带宽利用,降低 CPU 开销</strong></li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="8"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%207.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>图8a显示,使用批处理的单实例情况下,如果有32个线程,QPS提高了 2x,而多实例情况下,如果有4个线程,QPS的峰值超过 2.5x QPS。底层SSD中有限的IO并行性在很大程度上决定了多实例情况下日志记录线程的最佳数量</li>
</ul>
<h3 id="index">Index</h3>
<ul>
<li>不同于上面的记录过程中,总体吞吐量MemTable索引更新过程中的尺度在单实例和多实例的情况下,如图8 b所示</li>
<li>虽然更新的延迟 MemTable 增加从单个线程2.9微妙到 32 线程的 5.7 微妙。而且,多实例的情况明显优于单实例的情况。具体来说,在图8b中,前者在32 线程时吞吐量增益达到 10.5xQPS,而后者在 32 线程时仅提高了 3.7xQPS。这种性能差距主要源于同步开销和后者中共享并发 skiplist 的收益递减。这表明,尽管并发的 MemTable 允许多个线程并行地插入到 skiplist 中,<strong>但其可伸缩性是有限的</strong>。</li>
<li>综上所述,单实例和多实例情况在使用高性能硬件部署到当前KVS架构时,都显示出了各自在可伸缩性方面的优势和劣势。
<ul>
<li>对于<strong>WAL日志瓶颈</strong>,虽然单实例情况可以通过利用批处理机制而始终受益于线程伸缩,但多实例情况可以获得更高的日志吞吐量性能,但实例间的并行性有限。</li>
<li>另一方面,在多实例情况下总体索引更新性能比在单实例情况下更好,因为在前者中没有<strong>锁争用。</strong></li>
<li>此外,由于WAL和MemTable位于同一个KVS写关键路径上,在进程流中,前者位于后者之前,因此总体吞吐量受到两个进程中较慢进程的限制。这些观察和分析表明,<strong>充分利用底层高性能硬件的KVS处理体系结构的设计应该全面考虑实例间和实例内并行性、日志记录和索引之间以及计算和存储开销之间的相互作用和权衡</strong></li>
</ul>
</li>
<li>现象:<strong>单实例多线程以及启用 batch 能够带来写日志的性能提升,但不如多实例。多实例下 WAL 性能也会受到器件本身的并行性的影响</strong>
<ul>
<li><strong>结论:只使用 batch 的单实例多线程方案虽然能带来 WAL 的性能提升,但不如多实例</strong></li>
<li><strong>实例内的并行性是不够的,还需要考虑实例间的并行性。</strong></li>
</ul>
</li>
<li>现象:<strong>单实例多线程以及启用 batch 能够带来写 Memtable 的性能提升,但不如多实例。多实例优势更明显</strong>
<ul>
<li><strong>结论:只使用 batch 的单实例多线程方案虽然能带来 Index 的性能提升,但不如多实例</strong></li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="9"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%208.png" alt="Untitled" loading="lazy"></figure>
<h1 id="design-and-implementation">Design and Implementation</h1>
<ul>
<li>在前几节中,我们深入的实验分析促使我们提出了一个可移植的二维并行KVS框架,称为p2KVS,以有效地利用成熟的产品KVS实现(如RocksDB)和现代硬件的力量。p2KVS采用了以下三方面的设计方法。
<ul>
<li>利用实例间并行性,在多个KVS实例之间进行水平键空间分区。p2KVS采用多个KVS-worker线程,每个线程绑定在不同的核上。每个worker运行一个KVS实例,带有自己独立的WAL日志、MemTable和LSM-tree,从而避免共享数据结构上的争用</li>
<li>使用全局KV访问层公开实例内部并行性。p2KVS设计了一个全局KV访问层,将用户线程和kvs工作线程分开。接入层将来自应用程序的所有KV请求策略性地分配到worker队列,在有限数量的worker之间实现负载均衡。</li>
<li>使用基于队列的机会批处理来缓解日志记录和索引瓶颈。p2KVS在每个worker中提供了一种基于运行时队列的机会批处理机制,以分摊kv处理和日志记录的开销。</li>
</ul>
</li>
</ul>
<h2 id="arch">Arch</h2>
<ul>
<li>垂直维度多了一个访问层,水平维度维护了一组工作线程,每个线程跑一个 LSM 实例,且有自己的请求队列,绑定核心。</li>
</ul>
<figure data-type="image" tabindex="10"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%209.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>每个<strong>用户线程</strong>处理分配给自己的请求。
<ul>
<li>每个用户线程只根据分配策略将请求提交到相应 worker 的请求队列中 (1),</li>
<li>然后挂起自己而不会进一步消耗 CPU (2)</li>
</ul>
</li>
<li>工作线程
<ul>
<li>批量处理入队请求 【1】</li>
<li>在对应的 KV 实例中执行 【2】</li>
<li>请求处理结束【3】</li>
<li>挂起的拥有线程将被通知返回(3)</li>
</ul>
</li>
<li>请注意,请求处理会消耗worker的CPU资源。RocksDB中的主要和次要的压缩操作是由属于KVS实例的后台线程执行的。这些实例内并行性优化依赖于KVS实例的实现,而p2KVS与它们完全兼容</li>
</ul>
<figure data-type="image" tabindex="11"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2010.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>p2kv维护一个全局标准KV接口,如PUT、GET、DELETE、SCAN等,并期望对上层应用程序完全透明。但是,它将KV请求重定向到内部分片的KVS实例,提供了实例间的并行性。请注意,虽然数据库和应用程序通常利用用户特定的语义(例如,RocksDB[20]的列语义)来为底层的多个KVS实例分配键值对,但p2KVS为上层应用程序提供了标准的KV接口,而没有额外的语义。此外,p2KVS还扩展了异步写接口(例如, 𝑃𝑢𝑡 (𝐾, 𝑉 , 𝑐𝑎𝑙𝑙𝑏𝑎𝑐𝑘))), 一个用户线程不会被正在处理请求阻塞。</li>
</ul>
<h2 id="balanced-request-allocation">Balanced Request Allocation</h2>
<ul>
<li><strong>为了公平分配和调度,本文用了简单的基于取模的哈希函数来实现,对键 Hash 然后取模对应的 worker 数,的到线程 ID</strong>。worker 数的设定是根据测试的硬件的并行性来设定的,根据原文的硬件测试结果设置为了 8。基于哈希分区的优势:负载均衡,最小的开销,没有读放大(分区之间没有键重叠)</li>
<li>增加 worker 数或者调整哈希函数可能导致重新构建 KV 实例,可以考虑采用一致性哈希。我们的实验结果表明,即使在使用Zipfian分布的高度倾斜的工作负载下,散列函数仍然可以使热请求均匀地分布在各个分区上。由于实例之间存在不平衡的可能性,例如,大多数热请求偶尔会被散列到某个worker上,p2KVS 在单个实例下被降级为 RocksDB。此外,p2KVS 可以配置适当的分区策略,以很好地匹配工作负载的访问模式,例如使用多个独立的散列函数 (DistCache)或动态键范围(NovaLSM)</li>
<li>注意,这个键范围分区相当于用多个互斥子键范围扩展全局 lsm 树中每一层的容量。因此,分区可以在一定程度上减少 compaction 导致的写放大,因为多个实例增加了LSM-tree的宽度,同时降低了它的深度</li>
</ul>
<h2 id="opportunistic-request-batching">Opportunistic Request Batching</h2>
<ul>
<li>
<p>如3.4节所示,对于小的写操作,请求批处理是减少IO和CPU开销的有效方法。此外,一些kvs(如RocksDB)对读类型请求有很好的并行优化。这些特性有助于提高每个 worker 的整体表现。</p>
</li>
<li>
<p>为了提升内部并行性,<strong>引入一个基于队列的请求批处理调度技术,简称 OBM</strong>,如下图所示。</p>
</li>
<li>
<p>当工作线程处理请求时,用户线程将请求添加到请求队列中。当worker完成一个请求的处理后,它会检查请求队列。<strong>如果有两个或更多连续的相同请求类型的传入请求(例如,读类型GET或写类型PUT、UPDATE和DELETE),它们将合并成一个批处理请求,然后作为一个整体处理</strong>,如算法1所示。</p>
<figure data-type="image" tabindex="12"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2011.png" alt="Untitled" loading="lazy"></figure>
</li>
<li>
<p>对于写类型的请求,工作者将其作为WriteBatch处理。与 IO 级别的批处理(如RocksDB组日志记录)相比,这种请求级别的批处理更有利于减少线程同步开销。</p>
</li>
<li>
<p>对于读类型的请求,工作人员在KVS上并发查询它们。RocksDB提供了一个multiget接口,这是一个非常优化的操作来处理内部并发的键搜索,我们在实现中使用这个接口来处理读类型的批处理请求。</p>
</li>
<li>
<p>注意,worker 不会主动等待来捕获传入的请求。因此,这种批处理是机会主义的</p>
</li>
</ul>
<figure data-type="image" tabindex="13"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2012.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li><strong>OBM 可以通过消除同步和等待的开销来提高高并发工作负载下的处理效率。为了防止由于非常大的批处理请求而导致的尾延迟问题,我们为每批处理的请求数设置了预定义的上限(默认为32)</strong>。虽然<strong>不同类型的请求也可以在一个实例中并行处理,但OBM只会连续地合并相同类型的请求,以避免在使用异步接口时由于无序读写请求而导致的一致性问题</strong>。当队列在较低的工作负载下通常为空时,该方法只是降级为KVS,而不需要批处理。</li>
<li>总之,p2KVS 使用 OBM 机会主义地将多个小请求聚合为一个更大的请求,这不仅减少了写进程的软件和日志 IO 开销,而且还利用了并行读优化。与RocksDB 或其他 kvs 中的IO级批处理机制不同[SpanDB, KVell],p2KVS避免了在底层IO层合并写操作时引入额外的同步开销。</li>
</ul>
<h2 id="range-query">Range Query</h2>
<ul>
<li>像其他使用哈希索引的 KVS 一样,p2KVS 实现范围查询操作(即 range 和 SCAN )是一个挑战,因为相邻的键可能被物理分布到不同的实例。键空间分区意味着一个范围查询必须被 fork 到对应的 worker 中,覆盖指定的键范围。幸运的是,每个分片实例使用自己的 LSM-tree 结构来保持其内部键的排序,有利于范围查询。</li>
<li>RANGE和SCAN之间存在语义上的差异,导致它们在p2KVS中的实现存在一些差异。RANGE 操作指定一个开始键和一个结束键,并读出它们之间所有现有的 KV 对。不同的是,SCAN 操作指定了一个开始键和随后要读取的KV对的数量(即扫描大小)。</li>
<li>当底层IO带宽足够时,一个RANGE请求可以被分成多个子RANGE操作,由多个p2KVS实例并行执行,而无需额外的开销。</li>
<li>对于SCAN操作,请求的键在实例中的分布最初是未知的,因此每个KVS实例中的目标键的数量不是先验确定的。
<ul>
<li>保守的方法是构造一个全局迭代器,基于每个KVS实例的迭代器,串行遍历整个密钥空间中的密钥,类似RocksDB MergeIterator。</li>
<li>p2KVS还提供了另一种并行化方法,它首先在所有实例上使用相同的扫描大小执行SCAN操作,然后从所有返回值中过滤出请求的kv。这种方法会导致额外的读取,可能会影响性能。</li>
<li>然而,实现的简单性和易用性以及底层硬件提供的高带宽和并行性可以合理地证明它的使用是合理的。</li>
</ul>
</li>
</ul>
<h2 id="崩溃一致性">崩溃一致性</h2>
<ul>
<li>p2KVS 保证了与底层 KVS 实例相同级别的崩溃一致性,每个实例都可以在崩溃或失败后通过重放自己的日志文件来恢复。</li>
<li>大多数基于lsm树的 KVS 支持基于 WriteBatch 的基本事务,其中同一个事务中的更新由一个 WriteBatch 提交。当一个包含多个实例的事务被执行时,该事务被拆分为多个并行运行在这些实例上的 writebatch,如果在崩溃前只提交了其中的一部分,就会导致一致性问题。</li>
<li>为了解决这个问题,p2KVS 为每个写请求引入一个严格递增的全局序列号(GSN),以表明其唯一的全局顺序。GSN 可以作为原始日志序列号的前缀写入KVS日志文件。从同一个事务中分离出来的 writebatch 具有相同的 GSN 号,OBM 不会将它们与其他请求合并。</li>
<li>当一个实例崩溃时,p2KVS 根据崩溃实例日志中的最大 GSN 回滚所有实例中的日志记录请求。为了确保系统崩溃后的恢复,当事务初始化或提交时,p2KVS 将事务的 GSN 持久化到 SSD 上,从而在崩溃后通过取消每个 KVS 实例上相应的 WriteBatch 来回滚整个事务。例如,在图 11 中,在崩溃之前,事务A已经返回并记录了提交,事务B已经被kvs处理但没有提交,事务C还没有完成。当系统恢复时,p2KVS首先删除事务B和事务C的日志记录,因为事务日志显示最后一个提交的事务的GSN是事务A,然后对所有KVS实例执行恢复过程。我们进行了在写数据时杀死p2KVS进程的实验,结果表明p2KVS总是可以恢复到一致的状态。</li>
</ul>
<figure data-type="image" tabindex="14"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2013.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>目前,p2KVS专注于扩展基本KVS操作的性能(例如,批写和读),在不修改底层KVS代码的情况下,确保任何单个请求在高并发下的原子性和崩溃一致性。在未来的工作中,我们将采用现有的事务优化[37,48],并通过进一步利用KVS代码中的功能来支持更多的事务级别。例如,p2KVS可以使用RocksDB的快照特性来实现读提交事务隔离级别。每个worker都在WriteBatch处理之前创建一个实例的快照,其他读请求将访问该快照以避免脏读。当事务提交时,快照将被删除,事务中的更新将变得可见</li>
</ul>
<h2 id="portability">Portability</h2>
<ul>
<li>p2KVS 作为一种可移植的并行化框架,可以灵活地应用于现有的 kvs。本节描述p2KVS在两个代表性的kvs上的可移植性实现,即LevelDB(基于lsm树)和WiredTiger(基于B+树)。两者都使用WAL机制和共享索引结构</li>
<li>因为所有kvs都有三个基本功能,即初始化、提交请求和关闭。p2KVS将自己的逻辑插入目标KVS的这三个函数中,保持相应的API和进程不变。
<ul>
<li>在初始化步骤中,p2KVS创建多个实例和目录,在KVS的开放函数中存储它们自己的数据。</li>
<li>在请求提交步骤中,用户线程调用KV请求(例如put和get),并执行分配策略将请求插入到相应实例的队列中。实例 worker 从请求队列中获取头部请求,并调用相同的KVS API(例如put和get)来处理KV操作。如果KVS具有批处理请求的专用功能,如RocksDB的Writebatch和multiget,则可以相应地启用OBM机制。</li>
<li>当p2KVS关闭时,每个worker调用KVS的close API。此外,任何worker崩溃都会导致整个系统关闭</li>
</ul>
</li>
<li>p2KVS 的 OBM-write 功能可以在 LevelDB 上通过批写来执行,而在 WiredTiger 上没有批量写。尽管LevelDB和WiredTiger没有像multiget那样的批读功能,p2KVS仍然可以利用OBM并发提交多个读请求来利用内部并行性。5.6节的实验结果表明,p2KVS可以有效提高LevelDB的并行度,WiredTiger的读写速度明显加快。</li>
</ul>
<h1 id="evaluation">Evaluation</h1>
<ul>
<li>我们以最先进的kvs(包括基于LSM-Tree的RocksDB和PebblesDB,以及基于Btree的KVell)为基准,在微基准和宏基准上评估了一个基于RocksDB的p2KVS原型。PebblesDB[46]是一种典型的写优化解决方案,它减少了压缩的写放大。KVell[36]通过使用非竞争的工作线程维护多个b -树索引来利用线程级并行性(详细信息请参见5.5节)。我们还在5.6节中评估了LevelDB和WiredTiger版本的p2KVS,以证明其可移植性。</li>
</ul>
<h2 id="实验配置">实验配置</h2>
<ul>
<li>我们在使用两个Intel Xeon E5-2696 v4 cpu (2.20 GHz, 22核)、64 GB DDR4 DRAM和一个Intel Optane 905p 480 GB NVMe SSD的服务器上运行所有实验。Optane SSD具有高且稳定的读写带宽,分别为2.2 GB/s和2.6 GB/s。我们使用带有4个或8个 worker 的两种p2KVS配置,分别标记为p2KVS-4和p2KVS-8。</li>
<li>我们使用微基准来比较p2KVS和基线的峰值处理能力。我们使用<strong>带有16个用户线程的db_bench工具执行 100M 个随机PUT操作,以评估并发写性能</strong>。p2KVS的异步接口使能,显示峰值性能。我们还分别<strong>执行10M的GET操作和1M的SCAN操作来评估读性能</strong>。在macro-benchmarks,我们使用 YCSB 来生成合成工作负载,其主要特征是表1中总结。我们分别在<strong>8个和32个用户线程的2组实验中评估了强并发和弱并发的性能</strong>。在两个基准测试中,KV对的大小默认设置为<strong>128字节</strong></li>
</ul>
<h2 id="micro">Micro</h2>
<ul>
<li>写性能、IO 放大、带宽利用率</li>
<li>虽然PebblesDB优化了 Compaction,IO放大比RocksDB和 p2KVS-4 低,<strong>但由于它是基于LevelDB开发的,没有对并发写进行优化,因此IO放大比p2KVS-8高</strong>。p2KVS几乎充分利用了SSD的带宽,而<strong>RocksDB和PebblesDB的带宽利用率不到20%</strong>。这是因为<strong>p2KVS通过消除前端瓶颈更频繁地触发压缩</strong>。显然,p2KVS的性能优势来自于它对底层硬件的高效容量利用</li>
</ul>
<figure data-type="image" tabindex="15"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2014.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>
<p>在表2中,我们展示了在p2KVS和其他kvs上处理100M随机写操作时内存和cpu的使用情况。</p>
<ul>
<li>
<p>在所有kvs中,平均内存使用量小于1.5 GB。p2KVS-4和p2KVS-8的CPU消耗分别超过单核的 7x和12x。</p>
</li>
<li>
<p>p2KVS的这些较高的CPU使用率来自于4或8个工作线程和额外的后台线程。用户线程在提交请求后休眠,只消耗很少的CPU资源。</p>
</li>
<li>
<p>RocksDB中的每个用户线程都会重载几乎整个CPU核,导致16个线程时巨大的CPU占用。<strong>然而,由于频繁的线程同步和锁开销,它的吞吐量很低</strong>。因此,<strong>后台压缩线程占用的CPU资源较少。</strong></p>
</li>
<li>
<p><strong>因为PebblesDB没有针对并发写进行优化,所以大多数并发用户线程都处于等待状态,只占总CPU资源的一小部分</strong>。</p>
</li>
<li>
<p><strong>p2KVS的内存消耗来自底层RocksDB实例的内存使用总和。这是可以接受的,也是稳定的,因为RocksDB实例的内存使用量不会随着数据量的增加而增加</strong></p>
<figure data-type="image" tabindex="16"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2015.png" alt="Untitled" loading="lazy"></figure>
</li>
</ul>
</li>
<li>
<p>接下来,我们评估请求延迟作为负载强度的函数。我们在RocksDB和p2KVS上以不同的请求强度进行1M随机写操作。图13a显示了p2KVS和RocksDB在轻负荷下的平均延迟非常接近。</p>
<ul>
<li>然而,由于p2KVS具有更高的处理能力,它可以在相同的延迟下支持比RocksDB更高的强度。</li>
<li>我们进一步观察了尾部延迟,这是衡量KVS用户体验质量的一个重要指标。如图13 b, RocksDB遭受剧烈的延迟当请求峰值强度超过100 KQPS,而p2KVS能保证99푡百分比小于400 KQPS延迟低于1 ms的强度。</li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="17"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2016.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>
<p>下图展示了多个 workers 和 OBM 下的点查询性能。我们向RocksDB和p2kv发起10M GET请求。</p>
<ul>
<li>
<p>如图14a所示,在没有OBM的情况下,p2KVS的性能与RocksDB基本相同。</p>
</li>
<li>
<p>通过利用RocksDB和OBM p2KVS的读优化,可以实现几乎线性的可伸缩性,如图14b所示。启用OBM的p2kv -8的QPS比禁用情况高出7.5倍,RocksDB高出5.4倍。</p>
<figure data-type="image" tabindex="18"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2017.png" alt="Untitled" loading="lazy"></figure>
</li>
</ul>
</li>
<li>
<p>范围查询。我们加载100M 128字节KV对对系统进行预热,然后使用单个用户线程执行不同扫描大小的1M RANGE或SCAN操作。如图15所示,在RANGE查询中,</p>
<ul>
<li>p2KVS比RocksDB高出2.9个百分点。p2KVS在小范围扫描期间将QPS提高1.5倍,因为有足够的IO带宽来补偿读放大。短扫描的性能取决于查找操作,p2KVS对随机读的并行优化也加快了查找操作,从而提高了短扫描。</li>
<li>当扫描大小大于1000时,读放大倍数较高的p2KVS会使SSD IO容量饱和,性能与RocksDB相同。综上所述,p2KVS在RocksDB读优化的基础上进一步扩展了并行性的好处</li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="19"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2018.png" alt="Untitled" loading="lazy"></figure>
<h2 id="macro-benchmark">Macro-benchmark</h2>
<ul>
<li>我们评估了p2KVS相对于RocksDB在具有8或32个线程的YCSB工作负载下的有效性,如图16所示。PebblesDB的结果被排除在外,因为它填满了所有的内存,并且在写入数亿KV对时崩溃。</li>
<li>在写密集型工作负载(LOAD)下,有更多的用户线程,p2KVS表现出更高的速度。例如,p2KVS8在8个和32个用户线程的情况下,比RocksDB的性能分别高出2.4和5.2。这是因为p2KVS不仅集成了RocksDB的OBM请求级批处理优化,而且还通过多个非竞争worker提高了并行效率,特别是在高并发工作负载(如32线程)下。p2KVS-8的性能提升比p2KVS4更明显,说明worker的数量应该与硬件并行度相匹配,才能使性能最大化</li>
<li>在读密集型工作负载(B、C、D)下,p2KVS比RocksDB提高了1~2xQPS。这种读性能的提高<strong>不仅来自于使用OBM来利用RocksDB原来的读并行性,还来自于哈希分区索引和并行工作者的额外好处</strong>。在工作负载E(即95% SCAN和5% PUT)下,<strong>p2KVS的性能与RocksDB类似,因为p2KVS利用IO并行性所获得的增益被读取放大所抵消</strong>。</li>
<li>在混合工作负载(A, F)下,p2KVS的性能比RocksDB高出1.5~3.5,主要是因为对并发写过程进行了优化。</li>
</ul>
<figure data-type="image" tabindex="20"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2019.png" alt="Untitled" loading="lazy"></figure>
<h2 id="sensitivity-study">Sensitivity Study</h2>
<ul>
<li>我们进行敏感性研究,以了解不同设计参数和p2KVS的选择对整体性能的影响。我们仍然使用YCSB作为工作负载,并使用带有单个用户线程的RocksDB作为基线。</li>
</ul>
<h3 id="worker-数量和obm">worker 数量和OBM</h3>
<ul>
<li>我们改变启用和禁用OBM的 worker 数量。结果如图17所示。在写密集型工作负载LOAD下,实例间并行性可以分别用4个和8个实例提高3x和5x性能。OBM利用请求批处理进一步加快了写速度,使QPS最多提高了2x。结果表明,在小型kv的情况下,仅仅增加实例的数量并不能显著提高性能。但是,当应用OBM时,写吞吐量可以很好地扩展。
<ul>
<li>在读密集型工作负载C下,实例间并行化将性能提高了3.3x和5.8x,分别为4个和8个worker。OBM甚至在单个实例中提高了5x读性能,但在8个worker中只能提高2xQPS,因为这种级别的并行性几乎耗尽了SSD的容量,导致OBM进一步利用的带宽不足</li>
<li>在混合工作负载A和B中,如果没有OBM,在4个实例和8个实例下,总体性能分别提高了3.5x和6.5x。OBM在工作负载B下增加了2.2 ~4.2x的QPS,而在工作负载A下获得的好处较少,增加了1.8 x~3.2x的吞吐量。这是因为工作负载B有更多的混合读和写请求,限制了OBM的批处理大小,OBM只批处理相同类型的相邻请求</li>
<li>由于SSD的争用,过多的worker甚至会导致性能下降。图17的实验结果表明,8是最优 worker 数量。</li>
</ul>
</li>
</ul>
<figure data-type="image" tabindex="21"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2020.png" alt="Untitled" loading="lazy"></figure>
<h3 id="key-value-size">Key-value Size</h3>
<ul>
<li>接下来,我们观察不同KV尺寸的影响。我们在三个典型的工作负载(LOAD、a和C)中测试kv大小的性能,如图18所示。计算结果表明,<strong>小KV 比大 KV 从 OBM 中获得的收益更多</strong>。</li>
</ul>
<figure data-type="image" tabindex="22"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2021.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>图19 显示了 p2kv 在1KB KV下的性能,其速度比128字节KV下的速度要慢。<strong>OBM在大KV的写密集型工作负载下效率较低,因为合并大型日志IOs的好处很小。但是,OBM对于读密集型工作负载仍然有效</strong>。</li>
</ul>
<figure data-type="image" tabindex="23"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2022.png" alt="Untitled" loading="lazy"></figure>
<h3 id="对比-kvell">对比 KVell</h3>
<ul>
<li>KVell 使用多个worker来维护多个可以并行访问的独立 b 树索引。它对所有写请求使用就地更新,以避免写放大,并在内存中维护大索引和页面缓存,以加快查询。</li>
<li>通过使用宏基准测试,我们将带有4个或8个工作线程的KVell与p2KVS-4和p2KVS-8进行比较,如图20所示。我们将KVell的页面缓存大小配置为4GB,这消耗了可接受程度的内存,比每个RocksDB实例的8MB块缓存大得多。即使使用这种配置,由于内存中索引较大,KVell的最大内存消耗是22 GB,而p2KVS的内存消耗是3 GB。</li>
<li><strong>在写密集型工作负载(LOAD、A和F)下,p2KVS 的性能高于KVell</strong>。p2KVS 的点查询性能与KVell(工作负载B和D)相似,SCAN性能高于KVell(工作负载E),<strong>在工作负载 C 下,KVell由于具有大页面缓存和全内存索引,吞吐量高于p2KVS</strong>。</li>
</ul>
<figure data-type="image" tabindex="24"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2023.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>
<p>我们还记录并比较p2kv -8和KVell-8在连续100M随机写工作负载下的IO带宽、内存和CPU的利用率。KVell-8和p2KVS-8的吞吐量分别为2.5 MQPS和3.0 MQPS。</p>
<ul>
<li>如图21a所示,KVell虽然使用了就地更新来减少写放大,但在小型的128字节KV写操作下,只消耗约300 MB/s IO带宽。相比之下,LSM-tree更适合聚合小型IOs,使p2KVS能够充分利用IO带宽。</li>
<li>图21b显示,即使减去页面缓存的占用空间,KVell仍然比p2KVS多使用2x内存,因为它将所有索引存储在内存中,而LSM-tree则对磁盘上的数据进行排序以减少索引大小。同时,KVell的每个线程都维护一个较大的索引,导致每个核的平均CPU利用率超过80%。但是,p2KVS下的每个RocksDB实例在前台和后台运行多个线程,分别执行日志记录和压缩。因此,虽然p2KVS的总CPU利用率更高,但每个核消耗大约50%的CPU,如图21c和21d所示。这意味着p2KVS更适合于多核硬件环境,不依赖于单核性能。因此,尽管KVell使用了一个较大的内存索引和页面缓存来获得比RocksDB更高的性能,但p2KVS可以通过使用更少的硬件资源来利用快速ssd来获得比KVell更好的性能。</li>
</ul>
<figure data-type="image" tabindex="25"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2024.png" alt="Untitled" loading="lazy"></figure>
</li>
</ul>
<h3 id="portability-2">Portability</h3>
<ul>
<li>如4.6节所述,除了RocksDB,我们还将p2KVS移植到另外两个KVS, LevelDB和WiredTiger。在本节中,我们将评估p2KVS对提高两个KVS的并行性的效果。</li>
<li>图22显示了微基准测试下基于LevelDB实例的p2KVS的吞吐量。结果表明,即使LevelDB没有像RocksDB那样提供实例内并行性优化(例如,流水线写和多get), p2KVS仍然可以比单线程LevelDB分别提高 3.4x 和 5.3x 的随机写和读性能。通过多线程,p2KVS 为 LevelDB 带来了写并行性,而不会损失读性能。</li>
</ul>
<figure data-type="image" tabindex="26"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2025.png" alt="Untitled" loading="lazy"></figure>
<ul>
<li>p2KVS WiredTiger。图23显示了p2KVS在WiredTiger上的吞吐量。虽然WiredTiger不支持批写,但p2KVS仍然可以有效地将其读写吞吐量分别提高到8.4和15。在相同的线程数下,p2KVS的性能优于WiredTiger。此外,<strong>当worker的数量超过12时,写性能会下降,这意味着并行性的好处不足以弥补过多实例的开销</strong>。</li>
</ul>
<figure data-type="image" tabindex="27"><img src="https://blog.shunzi.tech/post-images/p2kvs/Untitled%2026.png" alt="Untitled" loading="lazy"></figure>
<h1 id="related-works">Related Works</h1>
<h2 id="优化-wal-和-mt">优化 WAL 和 Mt</h2>
<ul>
<li>一些研究尝试直接解决写 WAL 或 MT 步骤中的低性能问题。</li>
<li>WAL
<ul>
<li>SIGMOD’18 FASTER 提出了混和日志机制来在 DRAM 上存储日志的大部分,从而提升 WAL 的性能,但是牺牲了快速持久化</li>
<li>VLDB’10 Aether 采用了一些复杂的方法,如早期锁释放和 Flush 管道,以减少并发引起的小日志写入争用。</li>
<li>VLDB’20 Taurus: 通过使用日志序列号跟踪和编码事务,进一步优化这些技术并实现高效的并行日志记录方案。</li>
<li>FAST’21 SpanDB:通过使用异步请求接口,SPDK通过基于轮询的IO提供异步组日志记录和请求处理。</li>
</ul>
</li>
<li>Memory
<ul>
<li>FloDB, Accordion, WipDB, CruiseDB 通过修改内存组件的数据结构来提高 MemTable 的写性能。</li>
<li>FloDB 和 Accordion 在基于skiplist的MemTable顶部添加缓冲区。</li>
<li>WipDB 将 skiplist 替换为多个大型哈希表,并将 kv 对压缩到内存中而不是SSD中</li>
<li>CruiseDB 根据工作负载和 SLA 动态调整 MemTable 的大小,减少写阻塞。</li>
<li>p2KVS 兼容这些工作,并可以吸收他们的思想,同时使用高效的并行调度来避免WAL和内存结构上的争用</li>
</ul>
</li>
</ul>
<h2 id="优化-lsm-压缩">优化 LSM 压缩</h2>
<ul>
<li>为了有效利用 SSD 的带宽,许多解决方案通过修改 LSM-tree 结构来降低写放大。
<ul>
<li>PebblesDB[46]设计了一个碎片化的 lsm-树结构,它允许在树级别上重叠键范围,并减少了大部分压缩开销。</li>
<li>LSM-trie[50]、SifrDB[43]、Dostoevsky[18]、SlimDB[47] 和 ChameleonDB[58] 也设计了一些 LSM-tree 的变体,通过允许重叠键范围来减轻写放大</li>
<li>ForestDB[1]、WiscKey[41]、lwb-tree[53]、HashKV[9]、UniKV[57]、DiffKV[38]采用KV分离机制,将KV对存储在多个日志文件中,并以LSM-tree级别记录键指针对,减少了大容量KV对的写放大。</li>
<li>虽然p2KVS的共同目标是优化基于lsm树的kvs的IO效率,但作为一个用户空间调优器,它与这些解决方案是正交和互补的,并且具有高度的可移植性,可以在现有的kvs上实现。</li>
</ul>
</li>
</ul>
<h2 id="设计非-lsm">设计非 LSM</h2>
<ul>
<li>一些研究设计了新的高性能ssd结构来代替lsm树。
<ul>
<li>KVell[36]在内存中维护大型基于b树的索引和页面缓存,以确保现代快速ssd上的GET和SCAN性能。</li>
<li>uDepot[33]将数据存储在由哈希表映射的无序段中,并通过利用任务运行时系统的异步用户空间IO来充分利用ssd盘。</li>
<li>Tucana[45]使用B𝜖-tree来减少开销和压缩的IO放大。</li>
<li>SplinterDB[14]基于B𝜖-tree设计STB𝜖-tree,针对硬件并行度高的ssd进行优化。</li>
<li>虽然这些新的索引结构充分利用了现代SSD,但p2KVS采用了一种正交方法,将KVS和SSD视为黑盒,因此继承了广泛使用和优化良好的基于lsm树的KVS(如RocksDB)和SSD经过时间考验的理想特性,提供了高可移植性。</li>
</ul>
</li>
</ul>
<h2 id="分片-kvs">分片 KVS</h2>
<ul>
<li>分布式数据库将表空间分区到多个平面上,并将它们存储在节点之间的不同KVS实例中。[2,11,27,60]
<ul>
<li>HBase, Bigtable,Nova-LSM,SolarDB</li>
</ul>
</li>
<li>最近,运行在高性能硬件上的基于LSM-tree的OLTP存储引擎使用了多个LSM-tree实例,每个实例用于存储一个表、子表或索引[19,26,52]。
<ul>
<li>The RocksDB Experience, X-Engine,Revisiting the Design of LSM-tree Based OLTP Storage Engine with Persistent Memory</li>
</ul>
</li>
<li>使用特定的接口语义(例如列)或动态调度策略来确定键值对所在的实例。</li>
<li>而p2KVS采用键空间分片的方式,在不使用数据库语义的情况下,均匀地将 KVs 分配给多个 worker,加快了全局 KVs 的速度。</li>
</ul>
<h1 id="conclusion">Conclusion</h1>
<ul>
<li>在生产级键-值存储环境中,系统管理员希望通过简单地将慢速hdd替换为快的ssd来获得一致的性能提升。结果往往令人失望,特别是对于普遍存在的小型KV工作负载。我们发现,在单线程和并发的写工作负载下,KVS写进程中的前台操作(日志记录和索引)可能成为严重的性能瓶颈。我们提出了一种便携式并行引擎p2KVS,基于lsm树的KVS的多个实例,以有效和高效地执行KV操作。p2KVS旨在利用这些实例之间和实例内部固有的并行性,以充分利用现代CPU、内存和存储硬件提供的处理能力。与最先进的RocksDB相比,p2KVS的写性能和读性能分别提高了4.6x和5.4x</li>
</ul>
<h1 id="一些问题">一些问题</h1>
<h2 id="multiget-带来的性能提升和数据路由策略之间的关系">Multiget 带来的性能提升和数据路由策略之间的关系</h2>
<ul>
<li>hash 公平路由之后一定程度丢失了数据的局部性信息,为什么 multiget 性能还能带来这么多优势</li>
</ul>
<p><a href="https://github.com/facebook/rocksdb/wiki/MultiGet-Performance">MultiGet Performance · facebook/rocksdb Wiki</a></p>
<h3 id="multiget">Multiget</h3>
<ul>
<li>在底层的RocksDB实现中查找键非常复杂。这种复杂性导致了大量的计算开销,主要是由于探测布隆过滤器时缓存丢失、虚函数调用分发、键比较和IO。需要查找许多键来处理应用程序级请求最终会在一个循环中调用 Get() 来读取所需的kv。通过提供接收一批键的 MultiGet() API, RocksDB 可以通过减少虚函数调用和 pipeline cache miss 的数量来提高 CPU 查找的效率。此外,可以通过并行执行 IO 来减少延迟。</li>
</ul>
<p><strong>读路径</strong></p>
<ul>
<li>一个典型的 RocksDB 数据库实例有多个级别,每个级别包含几十到数百个SST文件。点查找经过以下几个阶段(为了简单起见,我们忽略合并操作数并假设所有操作都是Put)
<ol>
<li>可变memtable被查找。如果为memtable配置了bloom过滤器,则使用整个键或前缀探测该过滤器。如果结果为正,则执行memtable rep查找。</li>
<li>如果没有找到键,将使用与#1相同的进程查找 0 个或多个不可变memtables</li>
<li>接下来,逐级查找SST文件如下-
<ol>
<li>在L0中,每个SST文件都是按倒序查询的 (因为倒序顺序即为新到旧)</li>
<li>对于L1及以上,每一层都有一个SST文件元数据对象的 vector,每个元数据对象包括文件中的最高键和最低键。在这个向量中执行二进制搜索,以确定与所需键重叠的文件。有一个辅助索引,它使用关于 LSM 中文件范围的预计算信息来确定下一层中与给定文件重叠的文件集。在 L1 中执行完整的二分搜索,该索引用于缩小后续级别的二分搜索边界。这就是所谓的分数级联。fractional cascading</li>
<li>一旦找到候选文件,就加载该文件的 bloom filter 块(从块缓存或磁盘),并探测 key。这个探测很可能会导致 CPU cache miss。在很多情况下,最底层不会有 bloom filter。</li>
<li>如果探测结果为阳性,则加载SST文件索引块并进行二进制查找,找到目标数据块。筛选器和索引块可能必须从磁盘读取,但通常它们要么固定在内存中,要么被频繁地访问,以便在块缓存中找到它们。</li>
<li>加载数据块并进行二进制搜索以找到密钥。数据块查找更有可能在块缓存中丢失,从而导致IO。需要注意的是,<strong>每个块缓存查找也可能导致CPU缓存丢失,因为块缓存是由哈希表索引的</strong>。</li>
</ol>
</li>
<li>对每一层重复步骤 #3,L2和更高一级的惟一区别是SST文件查找的部分级联。</li>
</ol>
</li>
</ul>
<p><strong>MultiGet 性能优化</strong></p>
<ul>
<li>让我们考虑具有良好参考局部性的工作量的情况。在这种工作负载中连续的点查找可能会重复访问相同的 SST 文件和索引/数据块。对于这样的工作负载,MultiGet提供了以下优化-
<ul>
<li>当选择。设置 cache_index_and_filter_blocks=true 时,SST文件的过滤块和索引块将在每次键查找时从块缓存中提取。在有多个线程执行读操作的系统上,这将导致 LRU 互斥锁上的严重锁争用。对于与 SST 文件密钥范围重叠的整批密钥,MultiGet 只在块缓存中查找过滤器和索引块一次,从而大大减少了LRU互斥锁争用。</li>
<li>在步骤1、2和3c中,由于bloom filter探针,CPU cache miss会发生。假设一个数据库有6个级别,大多数键都在最底层找到,平均有2个L0文件,我们将有~6次缓存丢失,因为在SST文件中查找过滤器。如果配置了memtable bloom过滤器,可能会有额外的1-2个缓存丢失。通过对每个阶段的查找进行批处理,过滤器缓存行访问可以流水线化,从而隐藏了缓存 miss 延迟。</li>
<li>在大型数据库中,读取数据块很可能需要IO。这引入了延迟。MultiGet能够并行地对同一个SST文件中的多个数据块发出IO请求,从而减少延迟。这依赖于底层Env实现在同一线程中对并行读取的支持。在Linux上,PosixEnv 具有使用IO Uring接口为MultiGet()做并行IO的能力。IO Uring 是从5.1开始在Linux内核中引入的一种新的异步IO实现。注意,MultiGet有多种实现。只有返回void的方法执行并行IO。</li>
</ul>
</li>
</ul>
<h2 id="对比为什么不和多实例-rocksdb-对比">对比为什么不和多实例 RocksDB 对比</h2>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[InfiniFS: An Efficient Metadata Service for Large-Scale Distributed Filesystems]]></title>
<id>https://blog.shunzi.tech/post/InifniFS/</id>
<link href="https://blog.shunzi.tech/post/InifniFS/">
</link>
<updated>2022-04-05T03:40:00.000Z</updated>
<content type="html"><![CDATA[<blockquote>
<ul>
<li>InfiniFS: An Efficient Metadata Service for Large-Scale Distributed Filesystems</li>
</ul>
</blockquote>
<h1 id="infinifs-an-efficient-metadata-service-for-large-scale-distributed-filesystems">InfiniFS: An Efficient Metadata Service for Large-Scale Distributed Filesystems</h1>
<h1 id="摘要">摘要</h1>
<ul>
<li>现代数据中心更喜欢一个文件系统实例,它跨越整个数据中心并支持数十亿个文件。在这样的场景中,文件系统元数据的维护面临着独特的挑战,包括在保持局部性的同时实现负载平衡、长路径解析和接近根的热点。</li>
<li>为了解决这些挑战,我们提出了INFINIFS,这是一种针对大规模分布式文件系统的高效元数据服务。它包括三个关键技术。
<ul>
<li>首先,INFINIFS 解耦了目录的访问和内容元数据,因此可以通过元数据位置和负载均衡对目录树进行分区。</li>
<li>其次,INFINIFS 设计了预测路径解析,以并行遍历路径,这大大减少了元数据操作的延迟。</li>
<li>第三,INFINIFS 在客户端引入乐观访问元数据缓存,缓解了近根热点问题,有效提高了元数据操作的吞吐量。</li>
</ul>
</li>
<li>广泛的评估表明,INFINIFS在延迟和吞吐量方面都优于最先进的分布式文件系统元数据服务,并为多达1000亿个文件的大规模目录树提供稳定的性能。</li>
</ul>
<h1 id="introduction">Introduction</h1>
<ul>
<li>数据中心文件数量很多,很容易达到当前分布式文件系统单个实例的容量,所以一个数据中心一般分成很多个集群,每个集群跑一个分布式文件系统实例。但是如果整个数据中心只跑一个文件系统实例,这样做是比较理想的,因为提供了全局的数据共享,提高了资源利用,以及很低的操作复杂度。
<ul>
<li>例如,Facebook 引入了 Tectonic 分布式文件系统,将小型存储集群整合到一个包含数十亿个文件的单一实例中</li>
</ul>
</li>