-
Notifications
You must be signed in to change notification settings - Fork 0
/
atom.xml
485 lines (316 loc) · 261 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
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Fu Zhe's Blog</title>
<link href="http://fuzhe1989.github.io/atom.xml" rel="self"/>
<link href="http://fuzhe1989.github.io/"/>
<updated>2022-12-17T14:21:34.387Z</updated>
<id>http://fuzhe1989.github.io/</id>
<author>
<name>Fu Zhe</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<entry>
<title>Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework for the Many-Core Age</title>
<link href="http://fuzhe1989.github.io/2022/12/17/morsel-driven/"/>
<id>http://fuzhe1989.github.io/2022/12/17/morsel-driven/</id>
<published>2022-12-17T14:21:32.000Z</published>
<updated>2022-12-17T14:21:34.387Z</updated>
<content type="html"><![CDATA[<p><strong>TL;DR</strong></p><blockquote><p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/2588555.2610507">Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework for the Many-Core Age</a></p></blockquote><span id="more"></span><h2 id="Introduction"><a href="#Introduction" class="headerlink" title="Introduction"></a>Introduction</h2><p>作者希望能解决现代系统架构中 query 执行的两个问题:</p><ol><li>如何充分利用多核能力。</li><li>如何在不同核之间均匀分发负载。注意即使统计信息本身无比精确,静态 dispatch 也会随着执行过程逐渐变得不均匀。</li></ol><p>作者因此选择的切入点是:</p><ol><li>将输入数据切分为大量小体积的 morsel,这样更容易做负载均衡。</li><li>将 plan 组织为若干个线性的 pipeline,每个 morsel 在 pipeline 中顺序执行,直到 pipeline breaker,之间不做线程切换,这样减少了执行过程因线程切换造成的性能损失。</li><li>task 调度考虑 NUMA,尽量避免数据在不同 NUMA 之间传输。</li><li>相比于传统 query plan 在执行前确定好并行度,morsel-driven 中并行度是动态调节的,避免了静态并行度导致的执行资源不均衡的问题。</li></ol><p>作者的依据:</p><ol><li>硬件的发展(多核、大内存)使得 query 执行的瓶颈由 IO 变为了内存。</li><li>NUMA 的引入令多核系统更像一个小集群,不同 NUMA node 之间的内存带宽成为了瓶颈。</li></ol><p>传统的 volcano 模型的并行可以被称为 plan-driven,即在 query 编译成 plan 的过程中就确定了要用多少线程,不同线程间再用 exchange 算子通信。但这种模型不能很好地适应现代系统。</p><p>morsel-driven 相对应地:</p><ol><li>使用固定数量的线程,每个线程绑到特定的 cpu 核上,从而将线程与 NUMA node 之间的映射固定下来。</li><li>尽量保持数据不在 NUMA 间迁移:每个线程处理的 input 来自 NUMA 本地,output 同样写到 NUMA 本地。</li><li>运行期动态调节并行度,甚至动态调节优先级。</li></ol><p><img src="/images/2022-12/morsel-driven-01.png"></p><p>morsel-driven 要求所有算子都要能以 morsel 为粒度并行执行,在传统的 volcano 模型基础上修修补补是不够的。这种新的执行框架的一个要素是 data locality,每个输入的 morsel,以及物化的输出,以及中间产生的各种数据结构(如 hash table)都会封装到一个共享的 state 中。state 本身具有高度的 NUMA locality:尽管允许被所有核/线程访问,但大部分时间只有创建 state 的 pipeline 会访问,而 pipeline 执行过程是绑定到核/线程上的,因此保证了高度的 NUMA locality。</p><blockquote><p>volcano 是在算子间交换数据,以算子为中心;morsel-driven 则是以数据为中心,在数据上迭代不同的算子,更符合分布式计算的理念。</p></blockquote><p>纯粹的 volcano 并行框架中,并行是被隐藏在算子内部的,算子间需要交换数据,因此需要在 plan 阶段插入 exchange 算子来做运行期的 data partitioning。partition 的好处是提高了 data locality,但这种好处不一定能抵消增加的开销。相反,morsel-driven 是将并行暴露在算子之外,通过 morsel 的 locality 来实现 data 的 locality,再辅以共享 hash table 等数据结构,因此不需要运行期 partitioning。</p><p>作者认为 morsel-driven 可以很容易地与其它系统集成:只要将其中的 exchange 算子替换为 morsel 执行即可。另外 morsel-driven 中的 pipeline 很适合结合 JIT。实际上 morsel-driven 背后的 Hyper 就在使用 JIT。</p><h2 id="Morsel-driven-execution"><a href="#Morsel-driven-execution" class="headerlink" title="Morsel-driven execution"></a>Morsel-driven execution</h2><p>morsel-driven 中 plan 是由若干个 pipeline 组成,每个 pipeline 包含若干个线性执行的算子:一个算子的输出是下个算子的输入。这样每个 pipeline 可以被编译为一个执行单元,输入一个 morsel,产生输出,中间的算子不会真的物化输出结果。一个 pipeline 在执行过程中可以产生多个实例,不同实例负责处理不同的 morsel。</p><p>具体实现上,QEPobject 负责根据数据依赖关系驱动 pipeline 执行。它会为每个 pipeline 在每个线程上创建一块 storage 存放输出结果。一个 pipeline 执行结束后,storage 中的输出会再被切分为均匀大小的 morsel 给后续 pipeline 作为输入。</p><p>每个 pipeline 结束后重新划分 morsel 有助于避免不同 morsel 产生的输出大小不均匀导致的数据倾斜。</p><p><img src="/images/2022-12/morsel-driven-02.png"></p><p><img src="/images/2022-12/morsel-driven-03.png"></p><p>图 3 中输入被 filter 之后划分为若干个 morsel,每个 morsel 会被 dispatcher 分给一个线程执行,因此会被直接写入到这个线程对应的 storage socket 中(根据颜色对应)。每个线程在处理完当前 morsel 之后,要么切换到另一个 task,要么再从它本地的 storage 中取出下个 morsel 继续执行。过程中并行度随时可以根据数据量进行调节。</p><p>在所有数据都被 filter 并分别写入不同线程的 socket 之后,才会开始 build hash table。此时数据大小已知,就可以提前 reserve 好 hash table。build 过程每个线程读取自己本地的 socket 中的 morsel,再将其插入到一个全局的无锁 hash table 中。</p><blockquote><p>插入全局 hash table 仍然会带来冲突。TiFlash 做了一个优化,尽量利用 data stream 本来就有的 hash 特性,避免使用全局 hash table(<a href="https://github.com/pingcap/tiflash/issues/4631">点这里</a>)。</p></blockquote><p><img src="/images/2022-12/morsel-driven-04.png"></p><p>如图 4,build 结束之后,probe pipeline 仍然是先 filter,然后将 morsel 写到不同线程的 socket,probe 算子读取 morsel,先后通过两个 hash table,再将输出写到每个线程专属的 socket 中。</p><p>上述过程中可以体现出 morsel-driven 的『pipeline』与 volcano 中的算子的区别:不同 pipeline 是协作进行的,每个 pipeline 会感知共享的数据结构、上下游数据依赖,因此可以生成最优化的执行逻辑。另外 pipeline 的实例数量也是根据数据变化的,可以有非常灵活的并行度调节。</p><h2 id="Dispatcher-Scheduling-Parallel-Pipeline-Tasks"><a href="#Dispatcher-Scheduling-Parallel-Pipeline-Tasks" class="headerlink" title="Dispatcher: Scheduling Parallel Pipeline Tasks"></a>Dispatcher: Scheduling Parallel Pipeline Tasks</h2><p>morsel-driven 中线程是预先创建好并绑核的,因此并行度的调节完全取决于 dispatcher,而不是线程数量。每个 pipeline 的一个运行实例称为一个 task,负责处理一个 morsel。</p><p>dispatcher 的三个主要目标:</p><ol><li>将 morsel 分配给它位于的 cpu 核,以保持 NUMA locality。</li><li>保证单个 query 的并行度有充分的弹性。</li><li>单个 query 涉及的多个 cpu 核之间负载均衡,让所有 pipeline 同时结束工作,避免有 cpu 核陷入等待。</li></ol><h3 id="Elasticity"><a href="#Elasticity" class="headerlink" title="Elasticity"></a>Elasticity</h3><p>每个 task 只处理一个 morsel 是为了保持执行的弹性,这样可以在运行期灵活调节并行度,比如逐渐减少一个长 query 的并行度,将算力让给另一个优先级更高的 query。</p><h3 id="Implementation-Overview"><a href="#Implementation-Overview" class="headerlink" title="Implementation Overview"></a>Implementation Overview</h3><p>morsel-driven 的实现中,每个 core/socket 有一大块预先分配好的内存空间,且会按需分配出 morsel。一个 core 在向 dispatcher 请求一个新的 task 后,对应 pipeline 的 storage 输入才会被从相应的 socket storage 中切出来(而不是预先分配好 pipeline 所需的空间)。</p><p><img src="/images/2022-12/morsel-driven-05.png"></p><p>上图看起来像是 dispatcher 自己也占一个线程,但实际不是。dispatcher 是被动调度的,本身实际是一个无锁的数据结构,由请求 task 的线程驱动。</p><p>每个 task 会不停从当前 socket 中取出下个 morsel 执行,这样避免了跨 NUMA 的访问。但为了避免执行长尾,当有 core 处理完本地 socket 的所有 morsel,请求下个 task 时,dispatcher 可能会从其它 socket『偷』一些 morsel 过来。</p><p>morsel-driven 支持 bushy 形状的 pipeline,比如『filter 并构建 hash table T』和『filter 并构建 hash table S』是可以并行执行的。但 bushy 并行的缺点也很明显,一个 query 中相互独立的 pipeline 数量往往比较少,限制了它的优势(理论上并行度高)。另外 bushy pipeline 可能需要比较大的空间来保存中间结果,会降低 cache locality。因此 morsel-driven 中每个 query 限制同时只能执行一个 pipeline,当前 pipeline 结束后再调度下个 pipeline。</p><p>morsel-driven 另一个相对于线程级并行的优势是更容易 cancel 一个 query,只需要在 dispatcher 标记,不需要 cancel 一个线程(通常不现实)。</p><h3 id="Morsel-Size"><a href="#Morsel-Size" class="headerlink" title="Morsel Size"></a>Morsel Size</h3><p>太小的 morsel 会导致 task 切换过于频繁,向量化失效;太大的 morsel 会影响 cache 和负载均衡。作者的实验中 10k 个 tuple 大小的 morsel 达到了最好的平衡。</p><h2 id="Parallel-Operator-Details"><a href="#Parallel-Operator-Details" class="headerlink" title="Parallel Operator Details"></a>Parallel Operator Details</h2><h3 id="Hash-Join"><a href="#Hash-Join" class="headerlink" title="Hash Join"></a>Hash Join</h3><p>如前所述,morsel-driven 中 hash join 最大的特点是:</p><ol><li>先收集数据,写到各个线程本地的 socket 中。这样可以得到一个比较理想的 hash table 的初始大小。</li><li>每个线程再将 tuple 插入到一个全局的无锁 hash table 中。</li></ol><p>这样就避免了传统的边 insert 边 grow 的最大缺点:hash table grow 开销巨大。</p><blockquote><p>在有锁的并行 hash table 构建中,grow 的开销进一步恶化了:grow 是要在临界区内的,即使分桶,也会阻塞所有线程。</p></blockquote><p>与高度优化的 radix-join 的对比:</p><ol><li>可以流水线处理更大的输入数据,且 probe 可以原地进行,更节省空间。</li><li>多个小的 hash table(维度表)可以组合起来 probe。</li><li>当 join 两个输入表的 cardinality 差别非常大时(实践中非常常见)morsel-driven 这种执行非常高效。</li><li>当 hash key 分布倾斜时表现更好。</li><li>对 tuple size 不敏感。</li><li>不需要硬件相关的参数。</li></ol><p>但作者也表示 radix-join 值得一试。</p><h3 id="Lock-Free-Tagged-Hash-Table"><a href="#Lock-Free-Tagged-Hash-Table" class="headerlink" title="Lock-Free Tagged Hash Table"></a>Lock-Free Tagged Hash Table</h3><p>morsel-driven 中的 hash table 中每个 bucket 是一个链表。它的特别之处是在链表头的指针中嵌入了一个 16 位的 filter,称为 tag。链表中每个元素会被 hash 为 tag 中的 1 位。显然这是一种非确定性的 filter,类似于 bloom filter。但在这个场景中,它的开销比 bloom filter 更低,却能达到类似的提前过滤的效果。</p><p>为了降低内存访问开销,hash table 使用 huge page(2MB),且通过 mmap 惰性分配。这样的好处是 page 会在随后线程构建 hash table 时分配,会落到各自的 NUMA node 上,这样多线程构建时各个 page 就大体上均匀分配在各个 NUMA node 上了。</p><h3 id="NUMA-Aware-Table-Partitioning"><a href="#NUMA-Aware-Table-Partitioning" class="headerlink" title="NUMA-Aware Table Partitioning"></a>NUMA-Aware Table Partitioning</h3><p>NUMA-aware 的 table scan 需要将数据分发到各个 NUMA node。如果在 dispatch 时能根据某些属性 hash,就能提升 data locality。注意这只是个 hint,实际运行时数据仍然可能因为 work-stealing 或者 data-skew 跑到其它 NUMA node 上。</p><p>另外这个优化不是决定性的,毕竟只要数据经过第一个 pipeline 之后就在 NUMA 本地了,因此这个优化最多对第一个 pipeline 有一定效果。</p><h3 id="Grouping-x2F-Aggregation"><a href="#Grouping-x2F-Aggregation" class="headerlink" title="Grouping/Aggregation"></a>Grouping/Aggregation</h3><p><img src="/images/2022-12/morsel-driven-08.png"></p><p>如图,agg 分两阶段:</p><ol><li>每个线程单独聚合,维护一个本地的固定大小的 hash table。当这个 hash table 满了之后,就会被写到 overflow partition 中。所有输入都被 partition 之后,线程间会交换不同 partition 的数据。</li><li>这一阶段每个线程分别扫描一个 partition 的数据,将其聚合为 thread local 的 hash table。每当有 partition 聚合好,它的输出会被立刻发给下一个算子,这样保证了下个算子执行时数据仍然大概率在 cache 中。</li></ol><p>注意 agg 与 join 的区别在于,agg 一定会破坏 pipeline(它需要 sink),不如直接引入 partition。而 join 的 probe 阶段是可以完全 pipeline 的,引入 partition 会破坏 pipeline,不如使用单个 hash table。</p><h2 id="Sorting"><a href="#Sorting" class="headerlink" title="Sorting"></a>Sorting</h2><p><img src="/images/2022-12/morsel-driven-09.png"></p><p>内存中 hash-join 和 hash-agg 通常快于 merge-sort-join 和 agg,因此 morsel-driven 中 sort 只用于 order by 或 top-k。</p><p>morsel-driven 中 sort 也是两阶段:</p><ol><li>每个线程本地 sort,之后选出 distribution key,所有线程将这些 distribution key 合并确定最终输出数组的各个区间的位置。</li><li>之后每个线程负责一个区间,将各个线程的对应数据 merge 到最终输出中。</li></ol>]]></content>
<summary type="html"><p><strong>TL;DR</strong></p>
<blockquote>
<p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/2588555.2610507">Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework for the Many-Core Age</a></p>
</blockquote></summary>
</entry>
<entry>
<title>[笔记] Facebook’s Tectonic Filesystem: Efficiency from Exascale</title>
<link href="http://fuzhe1989.github.io/2022/12/14/facebooks-tectonic-filesystem/"/>
<id>http://fuzhe1989.github.io/2022/12/14/facebooks-tectonic-filesystem/</id>
<published>2022-12-14T13:33:30.000Z</published>
<updated>2022-12-14T13:33:51.646Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://www.usenix.org/system/files/fast21-pan.pdf">Facebook’s Tectonic Filesystem: Efficiency from Exascale</a></p></blockquote><span id="more"></span><h1 id="Introduction"><a href="#Introduction" class="headerlink" title="Introduction"></a>Introduction</h1><ol><li>一个巨大规模的多租户系统 vs 多个专用的小系统。</li><li>EB 规模。</li></ol><p>之前 Facebook 有很多个规模不大的专属存储系统,导致的问题:</p><ol><li>开发、优化、管理复杂</li><li>资源利用率不足:不同服务的资源使用特点不同,有的瓶颈是 IOPS,有的是 CPU,分成多个系统导致了每个系统的资源利用率都达不到最优。</li></ol><p>Tectonic 希望用一个巨大的多租户系统替代这些分散的小集群,但有以下挑战:</p><ol><li>扩展性</li><li>租户间性能隔离</li><li>租户级别的优化</li></ol><p>扩展性:</p><ol><li>对于 fs 而言主要挑战在于 meta 管理。Tectonic 的解法是将 meta 分解为多个可扩展的独立层次,同时用 hash 分区(而不是 range 分区)来避免热点。</li></ol><p>性能隔离:</p><ol><li>将有类似流量特点和延时需求的应用分到一组</li><li>按应用组隔离,从而极大降低了管理难度</li></ol><p>租户级别优化:</p><ol><li>client 端驱动,用一种微服务架构来控制租户与 Tectonic 之间的交互。</li><li>对于数仓:使用 Reed-Solomon(RS)编码来减少空间占用、IOPS、网络流量。</li><li>对于 blob:先以多副本形式写入,降低写延时;再转换为 RS编码,降低空间占用。</li></ol><h1 id="Facebook’s-Previous-Storage-Infrastructure"><a href="#Facebook’s-Previous-Storage-Infrastructure" class="headerlink" title="Facebook’s Previous Storage Infrastructure"></a>Facebook’s Previous Storage Infrastructure</h1><h2 id="Blob-Storage"><a href="#Blob-Storage" class="headerlink" title="Blob Storage"></a>Blob Storage</h2><p>Facebook 之前混合使用了 Haystack 和 f4,前者以多副本形式存储热数据,待数据不那么热之后再移到 f4 转换为 RS 编码。</p><p>但这种温热分离导致了资源利用率低下。</p><p>HDD 的存储密度提升的同时,IOPS 却停滞不前,导致每 TB 对应的 IOPS 越来越少。结果就是 Haystack 瓶颈成了 IOPS,需要额外的磁盘来提供足够的 IOPS,使得它的有效副本数上升到了 5.3(对比 f4 的有效副本数是 2.8)。</p><p>此外 blob 生命期也在变短,经常在移到 f4 之前就删掉了,进一步加大了 Haystack 的有效副本数。</p><p>最后,它们两个是不同的系统。</p><blockquote><p>非常有趣的历史</p></blockquote><h2 id="Data-Warehouse"><a href="#Data-Warehouse" class="headerlink" title="Data Warehouse"></a>Data Warehouse</h2><p>数仓类的应用更重视读写的吞吐而不是延时,通常会发起比 blob 更大的读写请示。</p><p>HDFS 集群规模受到了 meta server 单点的限制,因此 Facebook 被迫搞出几十个 HDFS 集群来满足数仓应用。这就成了一个二维背包问题:将哪些应用分配到哪些集群可以使整体利用率最高。</p><blockquote><p>意思是非常复杂,难以优化。</p></blockquote><h1 id="Architecture-and-Implementation"><a href="#Architecture-and-Implementation" class="headerlink" title="Architecture and Implementation"></a>Architecture and Implementation</h1><h2 id="Tectonic-A-Bird’s-Eye-View"><a href="#Tectonic-A-Bird’s-Eye-View" class="headerlink" title="Tectonic: A Bird’s-Eye View"></a>Tectonic: A Bird’s-Eye View</h2><p><img src="/images/2022-11/tectonic-01.png"></p><p>Tectonic 集群只面向单个 datacenter,租户可在其上自行构建 geo-replication。</p><p>租户之间不会共享任何数据。</p><p>Tectonic 集群在同一套存储和 metadata 组件之上支持任意个 namespace(或 filesystem 目录树)。</p><p>应用通过一套 append-only 的层级式的 filesystem API 与 Tectonic 交互。</p><p><img src="/images/2022-11/tectonic-02.png"></p><ul><li>Chunk Store</li><li>Metadata Store</li><li>bckground services</li></ul><h2 id="Chunk-Store-Exabyte-Scale-Storage"><a href="#Chunk-Store-Exabyte-Scale-Storage" class="headerlink" title="Chunk Store: Exabyte-Scale Storage"></a>Chunk Store: Exabyte-Scale Storage</h2><p>Chunk Store 是面向 chunk 的分布式对象存储,chunk 组成 block,block 再组成 file。</p><p>针对扩展性和多租户的两个功能:</p><ol><li>Chunk Store 结构上是平坦的(不受 filesystem 目录树的影响),可线性扩容。</li><li>不感知上层的 block 或文件语义,与 metadata 解耦,从而简化这一层的管理并提高性能。</li></ol><h3 id="Storing-chunks-efficiently"><a href="#Storing-chunks-efficiently" class="headerlink" title="Storing chunks efficiently"></a>Storing chunks efficiently</h3><p>每个 chunk 被存储为一个 XFS 上的本地文件。存储节点为 chunk 提供的 API 有 get、put、append、delete、list、scan。</p><p>存储节点需要负责本地资源在多租户间的公平分配。</p><p>每个存储节点有 36 块 HDD,另有 1TB SSD 存储 XFS metadata 和缓存热的 chunk。</p><h3 id="Blocks-as-the-unit-of-durable-storage"><a href="#Blocks-as-the-unit-of-durable-storage" class="headerlink" title="Blocks as the unit of durable storage"></a>Blocks as the unit of durable storage</h3><p>在上层看来 block 只是一个字节数组。实际上 block 是由若干个 chunk 组成,共同保证了持久性。</p><p>Block 既可能是 RS 编码的,也可能是多副本存储。Block 中的不同 chunk 会分布在不同的 fault domain(如不同的 rack)来提高容错性。</p><blockquote><p>看来 chunk 既可能是一个 strip 也可能是一个 replica。</p></blockquote><h2 id="Metadata-Store-Naming-Exabytes-of-Data"><a href="#Metadata-Store-Naming-Exabytes-of-Data" class="headerlink" title="Metadata Store: Naming Exabytes of Data"></a>Metadata Store: Naming Exabytes of Data</h2><p>Metadata store 将所有 filesystem 的 metadata 细粒度分区以简化操作、提升扩展性。不同 layer 的 metadata 逻辑上是分开的,各自再 hash 分区。</p><p><img src="/images/2022-11/tectonic-03.png"></p><h3 id="Storing-metadata-in-a-key-value-store-for-scalability-and-operational-simplicity"><a href="#Storing-metadata-in-a-key-value-store-for-scalability-and-operational-simplicity" class="headerlink" title="Storing metadata in a key-value store for scalability and operational simplicity"></a>Storing metadata in a key-value store for scalability and operational simplicity</h3><p>Tectonic 使用 ZippyDB 存储 metadata,其内部用到了 RocksDB,shard 间用 Paxos 保证一致性。每个副本都可以处理读请求,但只有 primary 可以提供强一致读。这一层只提供 shard 级别的事务。</p><h3 id="Filesystem-metadata-layers"><a href="#Filesystem-metadata-layers" class="headerlink" title="Filesystem metadata layers"></a>Filesystem metadata layers</h3><p>Name layer 提供了目录到目录项的映射。File layer 将每个文件映射为一组 block。Block layer 将每个 block 映射为一组 chunk(实际是磁盘上的位置),它还有个 disk 到 block 的倒排索引用于运维操作。</p><h3 id="Fine-grained-metadata-partitioning-to-avoid-hotspots"><a href="#Fine-grained-metadata-partitioning-to-avoid-hotspots" class="headerlink" title="Fine-grained metadata partitioning to avoid hotspots"></a>Fine-grained metadata partitioning to avoid hotspots</h3><p>Tectonic 这种分 layer 的管理方式将目录项操作(name layer)与文件操作(file 和 block layer)分离开,天然避免了两者干扰导致的热点。这与 Azure Data Lake Service(ADLS)做法很像,但 ADSL 是 range 分区,相比 Tectonic 的 hash 分区,前者更容易产生热点。</p><h3 id="Caching-sealed-object-metadata-to-reduce-read-load"><a href="#Caching-sealed-object-metadata-to-reduce-read-load" class="headerlink" title="Caching sealed object metadata to reduce read load"></a>Caching sealed object metadata to reduce read load</h3><p>Tectonic 支持『封存』(seal)block、file、dir,其中 dir 的封存不会递归,只是不再允许添加直接的目录项。封存后的对象就不再理发了,因此可以放心缓存在各种地方而不用担心一致性受损。这里的例外是 block 到 chunk 的映射:封存后的 chunk 仍然可以在磁盘间迁移,令 block layer 的 cache 失效。但这种失效会在读的过程中被检测出来并自动刷新。</p><h3 id="Providing-consistent-metadata-operations"><a href="#Providing-consistent-metadata-operations" class="headerlink" title="Providing consistent metadata operations"></a>Providing consistent metadata operations</h3><p>Tectonic 依赖底层 kv-store 提供的强一致操作和 shard 内原子的 read-modify-write 事务支持来实现目录内的强一致操作。</p><blockquote><p>mark,什么叫『强一致操作』?</p></blockquote><p>Tectonic 保证针对单个对象的操作(如 file 的 append、read,以及 dir 的 create、list 等)、以及不跨目录的 move 操作具有 read-after-write 一致性。</p><p>kv-store 不支持跨 shard 的事务,因此 Tectonic 也没办法支持原子的跨目录 move。这种 move 需要两阶段:首先在新目录创建一个 link(dir entry 吧我猜),再从旧目录删掉 link。</p><p>注意被移走的目录会维护一个指向旧的 parent 的指针,表明它的 move 还没有完成。</p><p>类似地,file 的跨目录移动是在新目录下复制一个新文件,再把老文件删掉。注意这里的复制只是 file 对象本身,底下的 block 仍然是复用的,不会有数据移动。</p><p>因为缺乏跨 shard 的事务,上述操作很容易产生 race。</p><p>想象目录 d 的名为 f1 的文件被 rename 为 f2。同时有人又在 d 下面创建了新的 f1 文件。</p><p>这里 rename 的操作顺序为:</p><ul><li>R1:在 name layer 的 shard(d) 获得 f1 的 fid</li><li>R2:在 file layer 的 shard(fid) 将 f2 添加为 fid 的另一个 owner</li><li>R3:在 name layer 的 shard(d) 创建 f2->fid 的映射,并删除 f1->fid 的映射。</li></ul><p>create 的操作顺序为:</p><ul><li>C1:在 file layer 的 shard(fid_new) 创建新文件</li><li>C2:在 name layer 的 shard(d) 创建 f1->fid_new 的映射,并删除 f1->fid 的映射。</li></ul><p>假设执行顺序为 R1-C1-C2-R3,R3 就容易把新创建的 f1->fid_new 的映射给误删除。因此这里一定要用单 shard 事务,确保 R3 只会在 f1->fid 时执行。</p><h2 id="Client-Library"><a href="#Client-Library" class="headerlink" title="Client Library"></a>Client Library</h2><p>Client lib 可以直接与 metadata server 和 chunk store 通信,以 chunk 为粒度读写数据,这也是 Tectonic 支持的最小粒度。</p><p>另外 client lib 直接负责 replication 和 RS-encode。</p><h3 id="Single-writer-semantics-for-simple-optimizable-writes"><a href="#Single-writer-semantics-for-simple-optimizable-writes" class="headerlink" title="Single-writer semantics for simple, optimizable writes"></a>Single-writer semantics for simple, optimizable writes</h3><p>Tectonic 限制每个文件同时只能有一个 writer,从而省掉了同步多个 writer 的负担。client 也因此可以并行写多个 chunk 以完成 replication。需要多 writer 语义的 tenant 可以在 Tectonic 之上自行实现同步。</p><p>Tectonic 通过 write token 来保证单 writer。每次 append open file 时会生成一个 token 记在 file metadata 中,之后每次 append block 都要带上正确的 token 才能修改 file metadata。如果有人再次 append open 这个 file,它的 token 会覆盖旧的 token,旧 writer 因此就会 append fail,保证了单 writer。新的 writer 会封存所有上个 writer 打开的 block,避免旧 writer 继续修改这些 block。</p><h2 id="Background-Services"><a href="#Background-Services" class="headerlink" title="Background Services"></a>Background Services</h2><p>Background service 负责维护不同 layer 间的一致性、修复数据丢失、平衡节点间的数据分布、处理 rack 空间满等问题,以及生成 filesystem 的各种统计信息。</p><h3 id="Copysets-at-scale"><a href="#Copysets-at-scale" class="headerlink" title="Copysets at scale"></a>Copysets at scale</h3><p>Copyset 是包含一份数据的所有 copy 的磁盘的 set,比如 RS(10, 4) 的 copyset 就是 14 块磁盘。如果任意选择磁盘组成 copyset,当集群规模变大时,集群抵御数据丢失的能力就会非常差:3 副本下同时挂 3 块盘几乎必然有 block 丢失全部 chunk。</p><p>因此通常大规模集群都会显式将磁盘/节点分成若干个 copyset 来避免数据丢失。但反过来,copyset 太少的话,每块磁盘对应的 peer 比较少,磁盘损坏就会给 peer 造成过大的重建压力。</p><p>Tectonic 的 block layer 和 rebalancer 会维护一个固定数量的 copyset,从而在维护成本和数据丢失风险上达到平衡。具体来说,它会在内存中维护 100 个所有磁盘的排列。每当有新 block 时,Tectonic 会根据 block id 选择一个排列,再从中选择连续的若干块磁盘组成一个 copyset。</p><p>rebalancer 会尽量保证某个 block 的 chunk 位于它所属的排列中(因为 copyset 可能随着磁盘加减而改变)。</p><h1 id="Multitenancy"><a href="#Multitenancy" class="headerlink" title="Multitenancy"></a>Multitenancy</h1><p>挑战:</p><ol><li>将共享资源公平地分配给每个租户。</li><li>能像在专属系统中一样优化租户的性能。</li></ol><h2 id="Sharing-Resources-Effectively"><a href="#Sharing-Resources-Effectively" class="headerlink" title="Sharing Resources Effectively"></a>Sharing Resources Effectively</h2><ol><li>提供近似(加权)公平的资源共享与性能隔离。</li><li>应用间弹性迁移资源,从而保持高资源利用率。</li><li>识别延时敏感的请求并避免这类请求被大请求阻塞。</li></ol><h3 id="Types-of-resources"><a href="#Types-of-resources" class="headerlink" title="Types of resources"></a>Types of resources</h3><ol><li>非临时的</li><li>临时的</li></ol><p>Storage capacity is non-ephemeral. Most importantly, once allocated to a tenant, it cannot be given to another tenant. Each tenant gets a predefined capacity quota with strict isolation.</p><p>存储容量是非临时的,一旦分配给一个租户,就不能再分配给另一个租户,从而保证严格的隔离。</p><p>需求时刻在变,且资源分配也可以实时改变的资源就是临时资源:</p><ol><li>IOPS</li><li>metadata 的查询 quota</li></ol><h3 id="Distributing-ephemeral-resources-among-and-within-tenants"><a href="#Distributing-ephemeral-resources-among-and-within-tenants" class="headerlink" title="Distributing ephemeral resources among and within tenants"></a>Distributing ephemeral resources among and within tenants</h3><p>租户作为临时资源管理的粒度太粗了,但应用又太细了。</p><p>Tectonic 中因此使用了应用组作为资源管理的单位,称为 TrafficGroup。相同 TrafficGroup 中的应用有着类似的资源和延时要求。Tectonic 支持每个集群有约 50 个 TrafficGroup。每个 TrafficGroup 从属于一个 TrafficClass,后者可以是金、银、铜,分别对应延时敏感、普通、后台应用。空闲资源会在租户内以 TrafficClass 作为优先级分配。</p><p>Tectonic 会按份额将资源分配给租户,租户内资源再按 TrafficGroup 和 TrafficClass 分配。空闲资源会先分配给同租户的其它 TrafficGroup,再考虑分配给其它租户。当一个 TrafficGroup 申请来自另一个 TrafficGroup 的资源时,这次分配对应的 TrafficClass 取两者中优先级低的那个。</p><h3 id="Enforcing-global-resource-sharing"><a href="#Enforcing-global-resource-sharing" class="headerlink" title="Enforcing global resource sharing"></a>Enforcing global resource sharing</h3><p>client 端的流控使用了一种高性能、准实时的分布式 counter 来记录最近一个很短的时间窗口内每个租户和 TrafficGroup 中每种资源的需求量。这里用到了一种漏斗算法的变种。将流控做到 client 端好处是可以避免 client 发出多余的请求。</p><h3 id="Enforcing-local-resource-sharing"><a href="#Enforcing-local-resource-sharing" class="headerlink" title="Enforcing local resource sharing"></a>Enforcing local resource sharing</h3><p>metadata 和 storage 节点本地会使用加权的循环调度器(weighted-round-robin scheduler),它会在某个 TrafficGroup 资源用超之后跳过这个 TrafficGroup 的请求。</p><p>storage 节点需要保证小 IO 请求不会受到大 IO 请求的影响而导致延时增加:</p><ol><li>WRR 调度器提供了一种贪心优化:低优先级的 TrafficClass 的请求可能会抢占高优先级的 TrafficClass 的请求,前提是后者离 deadline 还很远。</li><li>控制每块磁盘上发出的非最高优先级的 IO 请求数量。只要有最高优先级的 IO 请求在,超过阈值的非最优先请求就会被挡住。</li><li>如果有最高优先级 IO 请求排队时间过长,后续所有非最高优先的请求都会被挡住。</li></ol><blockquote><p>CloudJump 有类似上面 2、3 的调度算法</p></blockquote><h2 id="Multitenant-Access-Control"><a href="#Multitenant-Access-Control" class="headerlink" title="Multitenant Access Control"></a>Multitenant Access Control</h2><p>Tectonic 使用基于 token 的鉴权方式,有一个 authorization service 为每个顶层请求(如 open file)生成一个 token,用于请求下一层。这样每层都需要生成一个 token 来通过下一层的鉴权。整个鉴权过程是全内存的,只消耗若干个微秒。</p><h1 id="Tenant-Specific-Optimizations"><a href="#Tenant-Specific-Optimizations" class="headerlink" title="Tenant-Specific Optimizations"></a>Tenant-Specific Optimizations</h1><p>Tectonic 支持约十个租户共享使用一个文件系统,两种机制使租户级别的优化成为可能:</p><ol><li>client 可以控制绝大多数应用与 Tectonic 的交互。</li><li>client 端每个请求都可以单独配置。</li></ol><h2 id="Data-Warehouse-Write-Optimizations"><a href="#Data-Warehouse-Write-Optimizations" class="headerlink" title="Data Warehouse Write Optimizations"></a>Data Warehouse Write Optimizations</h2><p>数仓中文件只有在写完之后才对用户可见,且整个生命期不可修改。于是应用就可以在本地按 block buffer 写。</p><h3 id="Full-block-RS-encoded-asynchronous-writes-for-space-IO-and-network-efficiency"><a href="#Full-block-RS-encoded-asynchronous-writes-for-space-IO-and-network-efficiency" class="headerlink" title="Full-block, RS-encoded asynchronous writes for space, IO, and network efficiency"></a>Full-block, RS-encoded asynchronous writes for space, IO, and network efficiency</h3><p>这种方式也允许 block 直接以 RS-encode 方式写入,从而节省存储空间、网络带宽和磁盘 IO。这种『一次写成多次读』的模式也允许应用并行写一个文件的不同 block。</p><h3 id="Hedged-quorum-writes-to-improve-tail-latency"><a href="#Hedged-quorum-writes-to-improve-tail-latency" class="headerlink" title="Hedged quorum writes to improve tail latency"></a>Hedged quorum writes to improve tail latency</h3><p>与常见的将 chunk 发送给 N 个节点不同,Tectonic 会两阶段写:先发送 reservation 请求给各个节点,申请资源,再将 chunk 发给前 N 个节点。这样能避免将数据发给肯定会拒绝的节点。</p><p>例如在写 RS(9, 6) 数据时,Tectonic 会发送 reservation 请求给 19 个节点,再将数据真正发给响应的前 15 个节点。</p><p>这种方式在集群负载高时尤其有用。</p><h2 id="Blob-Storage-Optimizations"><a href="#Blob-Storage-Optimizations" class="headerlink" title="Blob Storage Optimizations"></a>Blob Storage Optimizations</h2><p>Tectonic 会将多个 blob 以 log-structured 形式写到一个文件中,从而降低管理 blob metadata 的压力。通常 blob 会远小于一个 block 大小,为了保证低延时,blob 会使用多副本形式写入(相比 RS 编码需要写完一个完整 block),从而能支持 read-after-write 语义。缺点是空间占用多于 RS 编码。</p><p><strong>Consistent partial block appends for low latency.</strong></p><p>Tectonic 多副本写入 blob 时只需要达到 quorum 即可返回,这会短时间内降低持久可靠性但问题不大:block 很快就会再被 RS 编码,而且 blob 还会再写到另一个 datacenter。</p><p>block 只能由创建它的 writer 进行 append。Tectonic 会在每笔 append 完成后将新的 block size 和 checksum commit 到 block metadata 中,再返回给调用者。</p><p>这种设计保证了 read-after-write 级别的一致性:write 返回之后 read 就可以读到最新的 size S。单一 appender 则极大降低了保持一致性的难度。</p><p><strong>Reencoding blocks for storage efficiency.</strong></p><p>client 会在每个 block seal 之后再将其转写为 RS 编码。相比在 append 时直接以 RS 编码写入,这种设计 IO 效率更高,每个 block 只需要在多个节点上分别写一笔。</p><h1 id="Tectonic-in-Production"><a href="#Tectonic-in-Production" class="headerlink" title="Tectonic in Production"></a>Tectonic in Production</h1><p>优化:</p><ol><li>不同租户共享一个集群使得 Tectonic 可以在租户间共享资源,如 blob 富余的磁盘带宽可以用于承接数仓存储负载的峰值。</li><li>数仓任务经常会集中访问名字接近的目录,如果 range 分区的话就容易产生热点。hash 分区就没有这个隐患。</li><li>同样地,如果相同目录的文件位于相同 shard,这个 shard 成为热点的可能性就非常大,hash 分区再次胜出。</li><li>Tectonic 的 list api 会同时返回文件名和 id,应用随后可以直接用 id 来访问文件,避免再次通过 Name layer。</li><li>数据在 RS 编码的时候既可以水平连续切 block,也可以竖直按 stripe 切 block。Tectonic 主要用连续的 RS 编码,这样大多数读请求都小于一个 chunk 的大小,就只需要一次 IO,而不像 stripe 那样每次读都要重建。在存储节点过载,直接读失败的时候会触发重建读。但这就容易造成连续的失败、重建,称为重建风暴。一种解法是使用 striping RS 编码,这样所有读都是重建读(这样真的算解决吗?),但缺点是所有读的性能都受到影响了。Tectonic 因此仍然用连续 RS 编码,但限制重建读的比例不超过 10%。</li><li>相比通过 proxy 中转,client 直接访问存储节点无论在网络还是在硬件资源效率上都更优,减少了每秒若干 TB 的一跳。但直连并不适合于跨 datacenter 的访问,此时 Tectonic 会将请求转发给与存储节点位于相同集群的无状态的 proxy。</li></ol><p>Tradeoffs:</p><ol><li>数仓应用在迁移到 Tectonic 后 metadata 的访问延时增加了。</li><li>Tectonic 不支持递归 list dir,因为需要访问多个 shard。同样地 Tectonic 也不支持 du,统计信息会定期按目录聚合生成,但这就意味着不实时。</li></ol><p>Lessons:</p><ol><li>微服务架构允许 Tectonic 渐进地实现高扩展性。<ol><li>一开始的架构将 metadata block 合并为 RS 编码的 chunk,显著减少了 metadata 的体积,但也显著削弱了容错性,因为少量节点的失败就会影响 metadata 的可用性。另外节点数的减少也阻碍了 quorum 写等优化的可能性。</li><li>一开始的架构没有区分 name 和 file layer,加剧了 metadata 热点。</li></ol></li><li>规模上去之后内存错误变得常见。Tectonic 因此会在进程边界内和边界之间校验 checksum(开销还挺大的)。</li></ol><p>Tectonic 的一个核心理念就是分离关注点(concern)。在内部,Tectonic 致力于让每一层专注于一些少量但集中的职责上(例如存储节点只感知 chunk,不感知 block 和 file)。Tectonic 自身在整个存储基础设施中的角色也是这种哲学的延伸:Tectonic 只专注于提供单个 datacenter 的容错。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://www.usenix.org/system/files/fast21-pan.pdf">Facebook’s Tectonic Filesystem: Efficiency from Exascale</a></p>
</blockquote></summary>
</entry>
<entry>
<title>C++:一个极简的静态反射 demo</title>
<link href="http://fuzhe1989.github.io/2022/11/09/cpp-a-minimal-static-reflection-demo/"/>
<id>http://fuzhe1989.github.io/2022/11/09/cpp-a-minimal-static-reflection-demo/</id>
<published>2022-11-09T13:52:21.000Z</published>
<updated>2022-11-09T13:53:11.045Z</updated>
<content type="html"><![CDATA[<p>下面这个类可以静态枚举字段:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">A</span> : Base {</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(<span class="type">int</span>, a, <span class="number">110</span>);</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(<span class="type">double</span>, b, <span class="number">1.2</span>);</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(std::string, c, <span class="string">"OK"</span>);</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(<span class="type">uint32_t</span>, d, <span class="number">27</span>);</span><br><span class="line"> std::string others;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>{</span><br><span class="line"> A a;</span><br><span class="line"> Helper::<span class="built_in">visit</span>([](std::string_view name, <span class="keyword">auto</span> &&value) {</span><br><span class="line"> std::<span class="built_in">print</span>(<span class="string">"name: {} value: {}\n"</span>, name, value);</span><br><span class="line"> }, a);</span><br><span class="line"></span><br><span class="line"> Helper::<span class="built_in">apply</span>([](<span class="type">int</span> a, <span class="type">double</span> b, std::string_view c, <span class="type">uint32_t</span> d) {</span><br><span class="line"> std::<span class="built_in">print</span>(<span class="string">"a: {} b: {} c: {} d: {}\n"</span>, a, b, c, d);</span><br><span class="line"> }, a);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><blockquote><p>感谢某同事手把手教会我写这个 demo</p></blockquote><span id="more"></span><p>这里用到的主要技巧:函数重载决议的时候,如果没有完美匹配实参的函数,编译器会选择能将实参隐式转换到形参的函数。</p><p>比如形参是实参的基类:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">Base</span> {};</span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">Derived</span> : Base {};</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f</span><span class="params">(Base)</span></span>;</span><br><span class="line"></span><br><span class="line"><span class="built_in">f</span>(Derived{}); <span class="comment">// f(Base) 会被选中</span></span><br></pre></td></tr></table></figure><p>进一步地,如果多个重载函数的形参都是实参的基类,则距离实参继承关系最近的基类版本会被选中:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">A</span> {};</span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">B</span> : A {};</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f</span><span class="params">(A)</span></span>;</span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f</span><span class="params">(B)</span></span>;</span><br><span class="line"></span><br><span class="line"><span class="built_in">f</span>(C{}); <span class="comment">// f(B) 会被选中</span></span><br></pre></td></tr></table></figure><p>那么如果我们维护一个继承链,对应一组重载函数,其中每个类型对应一个形参版本,我们能做到什么呢?</p><p>我们可以知道这个函数有多少个重载:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="type">size_t</span> N></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">Rank</span> : Rank<N - <span class="number">1</span>> {</span><br><span class="line"> <span class="type">static</span> <span class="keyword">constexpr</span> <span class="keyword">auto</span> rank = N;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">template</span> <></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">Rank</span><<span class="number">0</span>> {</span><br><span class="line"> <span class="type">static</span> <span class="keyword">constexpr</span> <span class="keyword">auto</span> rank = <span class="number">0</span>;</span><br><span class="line">};</span><br></pre></td></tr></table></figure><p>假设我们人肉定义了以下重载:</p><ul><li><code>Rank<0> f(Rank<0>)</code></li><li><code>Rank<1> f(Rank<1>)</code><br>…</li><li><code>Rank<50> f(Rank<50>)</code></li></ul><p>则我们用一个非常大的 <code>Rank</code> 就可以知道当前有多少个 <code>f</code>:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">std::cout << <span class="keyword">decltype</span>(<span class="built_in">f</span>(Rank<<span class="number">100</span>>{}))::rank << std::endl; <span class="comment">// 50</span></span><br></pre></td></tr></table></figure><p>进一步地,我们还能用某个常数索引取出对应的 <code>Rank</code>:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">std::cout << <span class="keyword">decltype</span>(<span class="built_in">f</span>(Rank<<span class="number">20</span>>{}))::rank << std::endl; <span class="comment">// 20</span></span><br></pre></td></tr></table></figure><p>回到文首的例子,如果我们能将每个 field 的类型作为一个重载函数的返回值,就可以用索引来得到对应 field 的类型了。</p><p>人肉写出来大约是这样:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">int</span> <span class="title">f</span><span class="params">(Rank<<span class="number">0</span>>)</span></span>;</span><br><span class="line"><span class="function"><span class="type">double</span> <span class="title">f</span><span class="params">(Rank<<span class="number">1</span>>)</span></span>;</span><br><span class="line"><span class="function">std::string <span class="title">f</span><span class="params">(Rank<<span class="number">2</span>>)</span></span>;</span><br><span class="line"><span class="function"><span class="type">uint32_t</span> <span class="title">f</span><span class="params">(Rank<<span class="number">3</span>>)</span></span>;</span><br></pre></td></tr></table></figure><p>抽象化大概长这样:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">ADD_FIELD</span>(T, name) <span class="function">T <span class="title">f</span><span class="params">(Rank<?>)</span></span></span><br></pre></td></tr></table></figure><p>这里的问题在于 <code>?</code> 怎么生成。我们想通过某种方式,生成一个整数序列,听起来是不是很递归?但函数声明怎么递归呢?</p><p>看起来,我们需要每声明一个 <code>f</code> 时从上一个 <code>f</code> 获得帮助递归的信息:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="function">T <span class="title">f</span><span class="params">(Rank<current_max_rank + <span class="number">1</span>>)</span></span>;</span><br></pre></td></tr></table></figure><p>那 <code>current_max_rank</code> 该怎么获取呢?从前面的例子中我们知道,我们可以用一个继承链末端的派生类来触发重载决议,从而得到当前 rank 最大的 <code>f</code> 的<strong>返回类型</strong>。因此我们还需要在返回类型中加上 rank 信息:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> T, <span class="type">size_t</span> N></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">TypeInfo</span> {</span><br><span class="line"> <span class="keyword">using</span> type = T;</span><br><span class="line"> <span class="type">static</span> <span class="keyword">constexpr</span> <span class="keyword">auto</span> rank = N;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> CURRENT_MAX_RANK = decltype(f(Rank<span class="string"><100></span>{}))::rank</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> NEXT_RANK (CURRENT_MAX_RANK + 1)</span></span><br><span class="line"></span><br><span class="line"><span class="function">TypeInfo<T, NEXT_RANK> <span class="title">f</span><span class="params">(Rank<NEXT_RANK>)</span></span>;</span><br></pre></td></tr></table></figure><p>这样我们每定义一个 field,就自动得到了一个具有更大 rank 的 <code>f</code>,其返回类型中就包含着我们要的信息。</p><p>接下来,我们需要为递归设置一个终点:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="function">TypeInfo<<span class="type">void</span>, 0> <span class="title">f</span><span class="params">(Rank<<span class="number">0</span>>)</span></span>;</span><br></pre></td></tr></table></figure><p>合起来,就是下面的代码啦:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">Base</span> {</span><br><span class="line"> <span class="function"><span class="type">static</span> TypeInfo<<span class="type">void</span>, 0> <span class="title">f</span><span class="params">(Rank<<span class="number">0</span>>)</span></span>;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> ADD_FIELD(Type, name, ...) \</span></span><br><span class="line"><span class="meta"> Type name{__VA_ARGS__}; <span class="comment">/* 用可选参数初始化 */</span>\</span></span><br><span class="line"><span class="meta"> static TypeInfo<span class="string"><Type, NEXT_RANK></span> f(Rank<span class="string"><NEXT_RANK></span>)</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">A</span> : Base {</span><br><span class="line"> ...</span><br><span class="line">};</span><br></pre></td></tr></table></figure><p>然后我们就可以利用这些信息枚举 <code>A</code> 中的每个 field 类型了:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> T, <span class="type">size_t</span> I></span><br><span class="line"><span class="keyword">using</span> FieldType = std::<span class="type">decay_t</span><<span class="keyword">decltype</span>(T::<span class="built_in">f</span>(Rank<<span class="number">100</span>>{}))>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// FieldType<A, 1> -> int</span></span><br><span class="line"><span class="comment">// FieldType<A, 2> -> double</span></span><br><span class="line"><span class="comment">// FieldType<A, 3> -> std::string</span></span><br><span class="line"><span class="comment">// FieldType<A, 4> -> uint32_t</span></span><br></pre></td></tr></table></figure><p>还能按顺序遍历 <code>A</code> 的每个字段:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> F, <span class="keyword">typename</span> T, <span class="type">size_t</span> I = <span class="number">1</span>></span><br><span class="line"><span class="type">void</span> <span class="built_in">visit</span>(F && f, T && t) {</span><br><span class="line"> <span class="keyword">if</span> <span class="built_in">constexpr</span> (I <= MaxRank<T>) {</span><br><span class="line"> <span class="built_in">f</span>(FieldType<T, I>::?);</span><br><span class="line"> <span class="built_in">visit</span><F, T, I + <span class="number">1</span>>(std::forward<F>(f), std::forward<T>(t));</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这里我们遇到的问题是:如何在遍历过程中拿到每个 field 的值。</p><p>我们可以在 <code>TypeInfo</code> 中增加一个 getter:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> T, <span class="type">size_t</span> N, <span class="keyword">auto</span> Getter></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">TypeInfo</span> {</span><br><span class="line"> <span class="keyword">using</span> type = T;</span><br><span class="line"> <span class="type">static</span> <span class="keyword">constexpr</span> <span class="keyword">auto</span> rank = N;</span><br><span class="line"> <span class="type">static</span> <span class="keyword">constexpr</span> <span class="keyword">auto</span> getter = Getter;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> ADD_FIELD(Type, name, ...) \</span></span><br><span class="line"><span class="meta"> Type name{__VA_ARGS__}; <span class="comment">/* 用可选参数初始化 */</span>\</span></span><br><span class="line"><span class="meta"> static TypeInfo<span class="string"><Type, NEXT_RANK, &T::name></span> f(Rank<span class="string"><NEXT_RANK></span>)</span></span><br></pre></td></tr></table></figure><p>注意这里我们获取的是成员变量指针,需要配合对象一起使用。接下来修改 <code>visit</code> 中调用 <code>f</code> 的地方:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">using</span> FT = FieldType<T, I>;</span><br><span class="line"><span class="built_in">f</span>(t.*FT::getter);</span><br></pre></td></tr></table></figure><p>这样我们就拿到了每个 field 的值。</p><p>接下来,我们还想拿 field name。可是字符串怎么放进 <code>TypeInfo</code> 中呢?<code>std::string</code> 和 <code>std::string_view</code> 都不能作为模板参数,那我们就将它转成字符数组:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="type">size_t</span> N></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">NameWrapper</span> {</span><br><span class="line"> <span class="function"><span class="keyword">constexpr</span> <span class="title">NameWrapper</span><span class="params">(<span class="type">const</span> <span class="type">char</span>(&str)[N])</span> </span>{ std::<span class="built_in">copy_n</span>(str, N, string); }</span><br><span class="line"> <span class="function"><span class="keyword">constexpr</span> <span class="keyword">operator</span> <span class="title">std::string_view</span><span class="params">()</span> <span class="type">const</span> </span>{ <span class="keyword">return</span> {string, N - <span class="number">1</span>}; }</span><br><span class="line"> <span class="type">char</span> string[N];</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">template</span> <NameWrapper Name, ...></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">TypeHelper</span> {</span><br><span class="line"> <span class="type">static</span> <span class="keyword">constexpr</span> std::string_view name = Name;</span><br><span class="line"> ...</span><br><span class="line">};</span><br></pre></td></tr></table></figure><blockquote><p>不太冷的冷知识:字符数组可以作为常量存在。</p></blockquote><p>于是上面的宏定义还得改:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">define</span> ADD_FIELD(Type, name, ...) \</span></span><br><span class="line"><span class="meta"> Type name{__VA_ARGS__}; <span class="comment">/* 用可选参数初始化 */</span>\</span></span><br><span class="line"><span class="meta"> static TypeInfo<span class="string"><NameWrapper(#name), Type, NEXT_RANK, &T::name></span> f(Rank<span class="string"><NEXT_RANK></span>)</span></span><br></pre></td></tr></table></figure><p>再改 <code>visit</code>:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">f</span>(FT::name, a.*FT::getter);</span><br></pre></td></tr></table></figure><p>终于,我们完成了 <code>visit</code>,还差个 <code>apply</code>。不分析了,直接给答案:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">class</span> <span class="title class_">T</span>, <span class="type">size_t</span> I></span><br><span class="line"> <span class="built_in">requires</span> (I > <span class="number">0</span> && I <= Size<T>)</span><br><span class="line"><span class="function"><span class="keyword">auto</span> <span class="title">getValue</span><span class="params">(T &a)</span> </span>{</span><br><span class="line"> <span class="keyword">using</span> Type = FieldType<T, I>;</span><br><span class="line"> <span class="keyword">return</span> a.*Type::getter;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> F, <span class="keyword">typename</span> T, std::<span class="type">size_t</span>... I></span><br><span class="line"><span class="function"><span class="keyword">auto</span> <span class="title">applyImpl</span><span class="params">(F&& f, T& t, std::index_sequence<I...>)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> <span class="built_in">f</span>(<span class="built_in">getValue</span><T, I + <span class="number">1</span>>(t)...);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> F, <span class="keyword">typename</span> T></span><br><span class="line"><span class="function"><span class="keyword">auto</span> <span class="title">apply</span><span class="params">(F &&f, T &t)</span> </span>{</span><br><span class="line"> <span class="keyword">return</span> <span class="built_in">applyImpl</span>(std::forward<F>(f), t, std::make_index_sequence<MaxRank<T>>{});</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这里用到的知识点:</p><ol><li><a href="https://en.cppreference.com/w/cpp/language/fold">fold expression</a></li><li><a href="https://en.cppreference.com/w/cpp/language/requires">requires</a></li><li><a href="https://en.cppreference.com/w/cpp/utility/integer_sequence">integer sequence</a></li></ol><p>以上基本照搬 <a href="https://en.cppreference.com/w/cpp/utility/apply">apply</a> 的实现。</p><p>由此,我们终于完成了这个极简的静态反射的 demo。</p><p><a href="https://godbolt.org/z/qsqe3aW6b">Compiler Explorer</a></p>]]></content>
<summary type="html"><p>下面这个类可以静态枚举字段:</p>
<figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">A</span> : Base &#123;</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(<span class="type">int</span>, a, <span class="number">110</span>);</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(<span class="type">double</span>, b, <span class="number">1.2</span>);</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(std::string, c, <span class="string">&quot;OK&quot;</span>);</span><br><span class="line"> <span class="built_in">ADD_FIELD</span>(<span class="type">uint32_t</span>, d, <span class="number">27</span>);</span><br><span class="line"> std::string others;</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>&#123;</span><br><span class="line"> A a;</span><br><span class="line"> Helper::<span class="built_in">visit</span>([](std::string_view name, <span class="keyword">auto</span> &amp;&amp;value) &#123;</span><br><span class="line"> std::<span class="built_in">print</span>(<span class="string">&quot;name: &#123;&#125; value: &#123;&#125;\n&quot;</span>, name, value);</span><br><span class="line"> &#125;, a);</span><br><span class="line"></span><br><span class="line"> Helper::<span class="built_in">apply</span>([](<span class="type">int</span> a, <span class="type">double</span> b, std::string_view c, <span class="type">uint32_t</span> d) &#123;</span><br><span class="line"> std::<span class="built_in">print</span>(<span class="string">&quot;a: &#123;&#125; b: &#123;&#125; c: &#123;&#125; d: &#123;&#125;\n&quot;</span>, a, b, c, d);</span><br><span class="line"> &#125;, a);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure>
<blockquote>
<p>感谢某同事手把手教会我写这个 demo</p>
</blockquote></summary>
</entry>
<entry>
<title>如何装作懂 Snapshot Isolation</title>
<link href="http://fuzhe1989.github.io/2022/11/01/how-to-pretend-to-understand-snapshot-isolation/"/>
<id>http://fuzhe1989.github.io/2022/11/01/how-to-pretend-to-understand-snapshot-isolation/</id>
<published>2022-11-01T14:32:15.000Z</published>
<updated>2022-11-01T14:36:13.143Z</updated>
<content type="html"><![CDATA[<blockquote><p>以下大量内容参考自 <a href="https://zhuanlan.zhihu.com/p/54979396">Snapshot Isolation综述</a>,不一一列举了。</p></blockquote><p>某天,某群,某位老师冒出来一个问题:</p><blockquote><p>话说我在想,snapshot isolation 这种的读不会被写阻塞是不是一个伪命题</p></blockquote><span id="more"></span><p>抛开问题本身,这引起了我的兴趣:如何正确理解 snapshot isolation。</p><p>字面意义上的 snapshot isolation 理解起来并不难:</p><blockquote><p>In databases, and transaction processing (transaction management), snapshot isolation is a guarantee that all reads made in a transaction will see a consistent snapshot of the database (in practice it reads the last committed values that existed at the time it started), and the transaction itself will successfully commit only if no updates it has made conflict with any concurrent updates made since that snapshot.</p><p><a href="https://en.wikipedia.org/wiki/Snapshot_isolation">Snapshot isolation</a></p></blockquote><p>从这个定义上来看,snapshot isolation 就是为每个事务的读准备一个系统的快照(snapshot),这个快照一旦建立就不会再被修改,从而达到了 isolation 的作用。但在事务提交时,如果系统当前状态与它的读快照不符了,这就是经典的 read-modify-write 的冲突,事务本身就要 abort。</p><p>我们可以将这句话拆成几部分:</p><ul><li>生成一个快照</li><li>修改系统的状态</li><li>冲突检测</li></ul><p>接下来我们采用 read-modify-write 的思路来尝试理解 snapshot isolation。</p><h2 id="不用-MVCC-可以实现-snapshot-isolation-吗"><a href="#不用-MVCC-可以实现-snapshot-isolation-吗" class="headerlink" title="不用 MVCC 可以实现 snapshot isolation 吗"></a>不用 MVCC 可以实现 snapshot isolation 吗</h2><p>常见的系统都是用 MVCC 来实现 snapshot isolation 的。我们来探讨一下其它方法为什么不行。</p><h3 id="原地修改"><a href="#原地修改" class="headerlink" title="原地修改"></a>原地修改</h3><p>对于原地修改的系统,一个新的修改会破坏一个已有的 snapshot。这样为了不破坏当前事务的运行,我们就只能阻止其它可能冲突的事务运行。</p><p>方法大家肯定都会,加锁呗。暴力点的就所有事务串行执行,温柔点的就把锁的粒度变小,把相互冲突的事务给串行化,不冲突的放行。</p><p>但通常我们管这个叫 serializable,不叫 snapshot isolation。</p><h3 id="copy-modify-write"><a href="#copy-modify-write" class="headerlink" title="copy-modify-write"></a>copy-modify-write</h3><p>如果将系统的状态复制出来,之后本地修改,最后再应用回系统,我们至少保证了事务的执行阶段是相互独立的,不影响并发度。</p><p>比如对于一个 LSM store,只要将 MemTable 和 Manifest 完整复制下来,就生成了一个显然正确的快照。说得好,但有点不好的地方:</p><ol><li>数据量大的时候复制成本过高。</li><li>为了保证系统状态一致,复制阶段需要避免有人修改系统状态,通常这意味着加锁。于是系统的并发又上不去了。地球不欢迎这样的 snapshot isolation。</li></ol><blockquote><p>别笑,我甚至参与开发过这样的系统。</p></blockquote><p>我们可以想办法降低复制的粒度,降到刚好是事务可能访问的数据集。但还是有些小问题:</p><ol><li>交互式事务不那么容易获得准确的数据集。</li><li>大事务的复制成本依然非常高。</li><li>依然意味着要加锁(取决于数据集大小)。</li></ol><p>另一个方向是降低复制的开销。比如对于上面的 LSM store,我们知道所有 SST file 是 immutable 的,持有 Manifest 就意味着一个不变的 view。而持有 Manifest 的开销是非常低的:只需要复制每个 SST 的 shared_ptr 之类的东西。这样复制开销主要就是复制 MemTable 了。如果我们将 MemTable 实现为 <a href="/2017/11/07/persistent-data-structure/">immutable 结构</a>,就可以以非常低的开销复制出来一个 MemTable。</p><p>对于 client-server 架构,如果要把数据集维护在 client 端,上面的优化就用不上了。如果可以把数据集维护在 server 端,就还是可以获得低开销的数据集复制。如果 server 因为某些原因清除了对应的数据集(如迫于内存/磁盘压力、server 重启等),client 要能正确 abort 事务并重试。</p><p>copy-modify-write 接下来会遇到的问题是,如何检测冲突?</p><ol><li>逐一对比 snapshot 中的每个值,一方面开销大,另一方面还会有 <a href="https://en.wikipedia.org/wiki/ABA_problem">ABA 问题</a>:我们怎么区分一个值没变和被修改多次最后回到了初始值呢?</li><li>分布式系统中检测本身会发生在多个节点上,为了避免一个节点检测通过之后又有新的写入破坏 snapshot,我们还是要回到加锁上来。</li></ol><p>上面第一个问题,如果不能直接检测值本身的变化,一个很自然的想法就是记录一个版本号来保留修改的痕迹。于是我们得到了 MVCC。</p><h2 id="MVCC"><a href="#MVCC" class="headerlink" title="MVCC"></a>MVCC</h2><p>实际上,snapshot isolation 暗含了 happen-before 关系,也就是 time。那很自然的想法就是把 time 保存到系统状态中,也就是 MVCC。</p><blockquote><p>Multiversion concurrency control (MCC or MVCC), is a concurrency control method commonly used by database management systems to provide concurrent access to the database and in programming languages to implement transactional memory.</p><p><a href="https://en.wikipedia.org/wiki/Multiversion_concurrency_control">Multiversion concurrency control</a></p></blockquote><p>我们保证系统中的每次状态修改都附带 timestamp,则 timestamp 本身就意味着一个 snapshot。</p><p>接下来的问题是,timestamp 从哪来。</p><h2 id="timestamp-分配"><a href="#timestamp-分配" class="headerlink" title="timestamp 分配"></a>timestamp 分配</h2><blockquote><p>参考 <a href="https://ericfu.me/timestamp-in-distributed-trans/">分布式事务中的时间戳</a></p></blockquote><p>某种角度我们可以将 timestamp 的分配方式分成几类:</p><ol><li>全序:<ol><li>使用中心节点产生单调增的 timestamp,任意两个 timestamp 之间都可以比较大小。Percolator、TiDB 等使用了这种方式生成 timestamp。</li><li>MySQL 的 ReadView 使用了事务序号,虽然不是 time,但它也是全序的。</li></ol></li><li>TrueTime:单独将它分为一类是因为……它太特殊了。TrueTime 接近具有全序性,除了两个相互重叠的 timestamp 无法比较之外。Spanner 专用。</li><li>偏序:HLC/LC,只有具有 happen-before 关系的两个 timestamp 才可以比较。</li><li>有上界的 HLC:通过 NTP 等方式为 HLC 增加一个偏移量的上限,这样就将每个 timestamp 转化成了类似于 TrueTime 的 timestamp range。同样地,两个 timestamp range 如果不重叠就可以比较大小。CRDB 的创新。</li></ol><p>timestamp 分配的开销从全序到偏序是逐渐下降的,那为什么有些系统还要使用开销大的全序方案?为了 serializable。</p><p>serializable 要求所有事务可以线性排在一个时间轴上,因此所有不具备全序性的 timestamp 都有可能引入异常(anomaly)而破坏 serializable。</p><blockquote><p>具体例子可以参考上面的文章。</p></blockquote><p>但为什么上面使用了非全序 timestamp 的 Spanner 和 CRDB 声称提供了 serializable 保证,而使用了全序 timestamp 的 Percolator/TiDB 却声称不保证 serializable 呢?</p><ol><li>Spanner 通过 read wait/commit wait 保证了任意两个事务的 timestamp 都是可比较的,从而实现了 strict/external serializable,即事务之间不仅仅可串行化,其执行顺序还与外界感知的因果关系相同。</li><li>CRDB 通过提升 write timestamp 的方式确保了可以正确检测出有因果联系的事务之间的读-写冲突,从而实现了 serializable snapshot isolation(强于 snapshot isolation 但弱于 strict serializable)。但它无法检测出两个没有因果联系的事务之间的潜在冲突。</li><li>Percolator 不能实现 serializable 的原因不在于 timestamp 分配方式,而是因为原始的 snapshot isolation 的冲突检测有着非常明显的问题。</li></ol><h2 id="snapshot-isolation、serializable-snapshot-isolation、write-snapshot-isolation"><a href="#snapshot-isolation、serializable-snapshot-isolation、write-snapshot-isolation" class="headerlink" title="snapshot isolation、serializable snapshot isolation、write snapshot isolation"></a>snapshot isolation、serializable snapshot isolation、write snapshot isolation</h2><p>原始的 snapshot isolation 的冲突检测规则是:事务提交时,检测它的 write-set 中是否有元素在 start_ts 到 commit_ts 之间被人修改过(已提交)。</p><p>为什么说它有着非常明显的问题?这篇文章一开始,我们很自然地将 snapshot isolation 类比为一次 read-modify-write。rmw 也有冲突检测,但我们都知道它检测的是 read-set 有没有被人修改过,而不是 write-set!</p><p>一种朴素的理解:事务的计算(modify)过程完全是基于 snapshot,因此为了保证事务正确提交,我们需要保证的是提交那一刻事务的 snapshot 假设仍然成立,而 snapshot 对应的就是 read-set。</p><p>更深刻的理解可以参考以下文章:</p><ul><li><a href="https://www.csd.uoc.gr/~hy460/pdf/adya99weak.pdf">Weak consistency: a generalized theory and optimistic implementations for distributed transactions</a></li><li><a href="https://www.cse.iitb.ac.in/infolab/Data/Courses/CS632/2015/2013/2011/Papers/p492-fekete.pdf">Making Snapshot Isolation Serializable</a></li><li><a href="https://ses.library.usyd.edu.au/bitstream/handle/2123/5353/michael-cahill-2009-thesis.pdf">Serializable Isolation for Snapshot Databases</a></li><li><a href="https://arxiv.org/pdf/1208.4179.pdf">Serializable Snapshot Isolation in PostgreSQL</a></li><li>A Critique of Snapshot Isolation</li></ul><p>这些文章最终都得出结论:检测读-写冲突才是 snapshot isolation 正确的打开方式。</p><p>其中:</p><ol><li>serializable snapshot isolation(ssi)提出要在读写事务 A 提交时检测它是否破坏了另一个已提交事务 B 的 snapshot。即事务 A 的 write-set 与事务 B 的 read-set 有重合,且两个事务在存活时间上也有重合。</li><li>write snapshot isolation(wsi)提出要在读写事务 A 提交时检测自己的 read-set 有没有被另一个已提交事务 B 破坏。即事务 A 的 read-set 与事务 B 的 write-set 有重合,且两个事务在存活时间上也有重合。</li></ol><p>wsi 还提出了不需要检测写-写冲突。这也非常容易理解:没有读的 blind write 不会和任何人冲突。</p><blockquote><p>考虑以下操作:<br><code>r1[x] w2[x] w1[x] c1 c2</code></p><p>如果T2没有读x,则不构成写丢失异常,因为可以重排为:<br><code>r1[x] w1[x] c1 w2[x] c2</code></p></blockquote><p>snapshot isolation 一开始为什么要设计成这样,我猜原因可能是检测成本:</p><ol><li>数据库中通常读远多于写,snapshot isolation 提出的目的也是为了降低写对只读事务的影响。</li><li>写-写冲突没有额外的存储成本:数据总是要写的,写的过程中顺便就完成了冲突检测。且事务提交之后它就变成了数据历史的一部分,不需要额外的空间来保存已提交事务的版本。</li><li>ssi 需要保存已提交事务的 read-set,开销远大于检测写-写冲突。使用粗粒度的 read-set 则会显著增大 false positive,导致更多事务被错误地 abort。</li><li>wsi 需要使用中心节点来检测,同样引入了额外开销。</li></ol><p>CRDB 的 ssi 在前人的基础之上又做了非常棒的创新:事务执行过程中检测到自己的 read-set 被破坏之后,就提升 snapshot 重试。这种重试是有上界的,保证了事务执行开销可控。这样它既避免了原始 ssi 维护 read-set 的巨大开销,又避免了引入 wsi 中的中心节点。</p><h2 id="不要破坏我的-snapshot!"><a href="#不要破坏我的-snapshot!" class="headerlink" title="不要破坏我的 snapshot!"></a>不要破坏我的 snapshot!</h2><p>我们回到一开始的问题:snapshot isolation 会阻塞只读事务吗?</p><p>答案是:可能哦。</p><p>想象一下,一个只读事务 A,它在发起 snapshot read 时,遇到一个未提交的事务 B,且 T<sub>s</sub>B < T<sub>s</sub>A,这意味着:</p><ol><li>如果最终 T<sub>c</sub>B < T<sub>s</sub>A,即 B 的提交早于 A 开始,则 B 应存在于 A 的 snapshot 中。</li><li>否则 B 的提交晚于 A 开始,B 不应存在于 A 的 snapshot 中。</li></ol><p>这里的第一种情况可能出现于 B 的提交请求已经发出,但未到达执行。</p><p>此时 A 是不知道 B 将用什么 ts 提交的,它只有以下选择:</p><ol><li>等,也就意味着阻塞。</li><li>不等,或者等一段时间后,直接 abort 自己(wait-die)。</li><li>不等,或者等一段时间后,直接 abort 事务 B(wound-wait)。</li></ol><p>另一方面,A 也可以选择一个比较老的 start timestamp,这样就能最大化避免被其它事务影响,但代价是读到的数据不新鲜。</p><p>无论如何,A 需要保证自己的 snapshot 不被破坏,它要么选择一个不再活跃的 snapshot(更早的 start ts),代价是可能读到过期数据;要么选择一个活跃的、还未定型的 snapshot,代价是需要仔细检查可能与之冲突的其它事务,毕竟是它自己选择了一个『虚假』的 snapshot。</p><p>假设 A 是读写事务,它能选择的 start ts 不能早于它的 read-set 中任何数据的已提交版本,否则事务一开始就直接与别人冲突了。</p><h2 id="不要破坏别人的-snapshot!"><a href="#不要破坏别人的-snapshot!" class="headerlink" title="不要破坏别人的 snapshot!"></a>不要破坏别人的 snapshot!</h2><p>考虑到异步执行,事务 A 提交时可能发现:</p><ol><li>它的 write-set 与已提交事务 B 的 write-set 有重合,且 T<sub>c</sub>A < T<sub>c</sub>B,此时如果写入会破坏 si。</li><li>它的 write-set 与已提交事务 B 的 read-set 有重合,且 T<sub>s</sub>B < T<sub>c</sub>A < T<sub>c</sub>B,此时如果写入会破坏 ssi。</li></ol><p>两种情况下,A 要么 abort 自身,要么选择更高的 commit ts 重试。</p><p>注意这种情况就是 si 优于 ssi/wsi 的地方了:si 的写-写冲突检测只需要与已提交数据的版本做比较,而 ssi/wsi 就需要想办法保留 B 的 read-set。真实系统,尤其是分布式系统很难承受这种维护代价。wsi 引入了中心节点,但这显著限制了系统的扩展能力。</p><p>以下是一些放弃精确性的改进方式:</p><ol><li>(可能是 CRDB?不记得了)记录粗粒度的 read version,强迫事务 A 选择更高的 commit ts 重试。</li><li>(FoundationDB)引入多个 resolver 分别维护一部分事务的 read-set 从而进行分布式的冲突检测。每个 resolver 都会记录通过了自己检测的事务的 read-set,但这些事务可能没有成功提交,因此引入了更高的 false positive。另外 FoundationDB 还限制了事务存活时间,极大降低了需要记录的事务集合。</li></ol><p>无论哪种改进,鉴于数据库本身的使用特点就是读远多于写,且写本身已经由 MVCC 维护着了,读-写冲突检测的开销因此必然高于写-写冲突。这也是 snapshot isolation 使用如此广泛的一个原因吧。</p><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><blockquote><p>你还不算入门呢</p></blockquote><p>感谢这句话作者的激励;感谢提出文首问题的老师帮助我更好地理解 snapshot isolation。</p>]]></content>
<summary type="html"><blockquote>
<p>以下大量内容参考自 <a href="https://zhuanlan.zhihu.com/p/54979396">Snapshot Isolation综述</a>,不一一列举了。</p>
</blockquote>
<p>某天,某群,某位老师冒出来一个问题:</p>
<blockquote>
<p>话说我在想,snapshot isolation 这种的读不会被写阻塞是不是一个伪命题</p>
</blockquote></summary>
</entry>
<entry>
<title>[笔记] Scaling Memcache at Facebook</title>
<link href="http://fuzhe1989.github.io/2022/09/26/scaling-memcache-at-facebook/"/>
<id>http://fuzhe1989.github.io/2022/09/26/scaling-memcache-at-facebook/</id>
<published>2022-09-26T05:35:14.000Z</published>
<updated>2022-10-17T04:12:25.554Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf">Scaling Memcache at Facebook</a></p></blockquote><p><strong>TL;DR</strong></p><p>在 cache coherence 方面经常被人引用的 paper。</p><span id="more"></span><h1 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h1><p>为什么要用单独的 cache service:</p><ol><li>读请求数量远多于写(高一个数量级)</li><li>多种数据源(MySQL,HDFS,以及其它后端服务)</li></ol><p><img src="/images/2022-09/memcached-01.png"></p><p>Facebook 用 cache service 的方式:</p><ol><li>作为 query cache 减轻 DB 的负担,见图 1。具体用法是按需填充(demand-filled)的 look-aside cache,即读请求先请求 cache,未命中再请求 DB,然后填充 cache;写请求直接写 DB,成功后再发请求给 cache 令对应的 key 失效(删除而未更新是为了保证幂等)。<blockquote><p><a href="https://www.quora.com/Is-Memcached-look-aside-cache">Is Memcached look-aside cache?</a></p><p>The distinction between look-aside and look-through caches is not whether data is fetched from the cache and memory in serial or in parallel. The distinction is whether the fetch to memory on a cache miss originates from the caller or the cache. If the fetch to memory originates from the caller on cache miss, then you’re using a look-aside cache. If the fetch to memory originates from the cache on cache miss, then you’re using a look-through cache.</p><p>应用自己处理 cache miss 之后的 fill 就是 look-aside,cache 自己处理 fill 就是 look-through,学废了没有?</p></blockquote></li><li>作为更通用的 key-value 存储(字面理解是不再绑定到具体的后端上了?)</li></ol><p>memcached 本身只是单机的,Facebook 将其修改为可以支持 cluster。这样,他们就多了两个需要处理的问题(在原有问题的基础上,笑):</p><ol><li>如何协调多个 memcached 节点。</li><li>如何维护 cache 与 DB 的一致性。</li></ol><p>这也是本文的两个重点:</p><ol><li>优化</li><li>一致性</li></ol><p><img src="/images/2022-09/memcached-02.png"></p><h1 id="In-a-Cluster-Latency-and-Load"><a href="#In-a-Cluster-Latency-and-Load" class="headerlink" title="In a Cluster: Latency and Load"></a>In a Cluster: Latency and Load</h1><h2 id="Reducing-Latency"><a href="#Reducing-Latency" class="headerlink" title="Reducing Latency"></a>Reducing Latency</h2><p>通常一个业务请求会对应很多次 cache 访问,比如作者的一份数据是平均 521 次 get(P95 是 1740 次)。在使用 memcached 集群后,这些 get 会根据某些 sharding 规则(实际是一致性 hashing)分散到不同的 memcached 节点。当集群规模增大后,这种 all-to-all 的通信模式会造成严重的拥塞。replication 可以缓解单点热点,但会降低内存的使用率。</p><p>Facebook 的优化思路是从 client 入手:</p><ol><li><p>将数据间的依赖关系梳理为 DAG,从而能并发 batch fetch 相互不依赖的数据(平均一个请求可以 fetch 24 个 item)。</p></li><li><p>完全由 client 端维护 router,从而避免 server 间的通信。</p></li><li><p>client 通过 UDP 发送 get 请求,从而降低延时。数据显示在高峰期 0.25% 丢包率。set 和 remove 仍然走 TCP。</p><p> <img src="/images/2022-09/memcached-03.png"></p></li><li><p>client 端使用滑动窗口来控制发出的请求数量,避免 response 把机架或交换机的网络打满。滑动窗口的拥塞策略类似于 TCP,即快下降+慢上升。与 TCP 不同的是,滑动窗口针对进程的所有请求生效,而 TCP 则是针对某个 stream 生效。如下图可见,窗口太小,请求并发上不去;窗口太大,容易触发网络拥塞。</p><p> <img src="/images/2022-09/memcached-04.png"></p></li></ol><h2 id="Reducing-Load"><a href="#Reducing-Load" class="headerlink" title="Reducing Load"></a>Reducing Load</h2><h3 id="Leases"><a href="#Leases" class="headerlink" title="Leases"></a>Leases</h3><p>作者引入 lease 是为了解决两类问题:失效的 set;惊群。前者发生在多个 client 并发乱序 update 一个 key 时。后者发生在一个很热的 key 不停被写,因此不停被 invalidate,读请求不得不反复请求 DB。</p><p>每个 memcached 实例可以为某个 key 生成一个 token 返回给 client,client 后续就可以带着这个 token 去更新对应的 key,从而避免并发更新。memcached 会在收到 invalidation 请求后令对应 key 的 token 失效。</p><p>为了顺便解决惊群问题,作者对 lease 机制加了个小小的限制:每个 key 最多每 10s 生成一个 token。如果时间没到就有 client 在 cache miss 下请求 token,memcached 会返回一个特定的错误,让 client 等一会重试。通常这个时间内 lease owner 就有机会重新填充 cache,其它 client 下次请求时就会命中 cache 了。</p><p>另一个优化是 memcached 在收到 invalidation 请求后,不立即删除对应的数据,而是暂存起来一小会。这期间一些对一致性要求不那么高的 client 可以使用 stale get 获取数据,而不至于直接把寒气传递给 DB(笑)。</p><h3 id="Memcache-Pools"><a href="#Memcache-Pools" class="headerlink" title="Memcache Pools"></a>Memcache Pools</h3><p>单独的 cache service 就要能承载不同的业务请求,但很多时间业务之间相互会打架,导致某些业务的 cache 命中率受影响。作者因此将整个 memcached 集群分成了若干个 pool,除了一个 default pool,其它的 pool 分别用于不同的 workload。比如可以有一个很小的 pool 用于那些访问频繁但 cache miss 代价不高的 key,另一个大的 pool 用于 cache miss 代价很高的 key。</p><p>作者给的一个例子是更新频繁(high-churn)的 key 可能会挤走更新不频繁(low-churn)的 key,但后者可能仍然很有价值。分开到两个 pool 之后就能避免这种负面的相互影响。</p><h3 id="Replication-Within-Pools"><a href="#Replication-Within-Pools" class="headerlink" title="Replication Within Pools"></a>Replication Within Pools</h3><p>replication 会被用于满足以下条件的 memcached pool:</p><ol><li>业务会定期同时获取大量 key。</li><li>整个数据集可以放进一两台机器。</li><li>但压力远高于一台机器的承载能力。</li></ol><p>这种情况下 replication 要比继续做 sharding 更好。</p><blockquote><p>空间换负载均衡</p></blockquote><h2 id="Handling-Failures"><a href="#Handling-Failures" class="headerlink" title="Handling Failures"></a>Handling Failures</h2><p>memcached 一旦无法服务,DB 就很容易被压垮,导致严重的后果。</p><p>如果整个 memcached 集群不可服务,所有请求都会被导到其它集群。</p><p>范围很小的不可服务会被自动处理掉,但处理前出错可能已经持续长达几分钟了,足以导致严重的后果。因此集群中会预留一小部分(1%)机器,称为 Gutter,用于临时接管这种小范围的机器不可服务。</p><p>client 如果收不到 response,就会假设目标 memcached 挂了,接着请求一台 Gutter 机器。如果再请求失败,就会查询 DB,再将 key-value 写到 Gutter 机器上。</p><p>Gutter 机器会快速令 cache 失效,这样它就不用处理 invalidation 请求,能降低负载。代价是一定的数据不一致。</p><p>这种方法不同于传统的 rehash data 的地方在于,rehash data 有扩大失败范围的风险。比如有 key 非常热的时候,它去哪,哪出问题。而使用 Gutter 就可以很好地将风险控制在指定的机器范围内。</p><p>Gutter 的另一个好处是将那些访问失败的请求集中了起来,增加了它们后续命中的机会,也因此降低了 DB 的负载。</p><h1 id="In-a-Region-Replication"><a href="#In-a-Region-Replication" class="headerlink" title="In a Region: Replication"></a>In a Region: Replication</h1><p>随着业务压力增长而无脑扩容反倒可能让问题恶化:</p><ol><li>业务请求越多,热点 key 越热。</li><li>随着 memcached 节点增多,网络拥塞也会越来越严重。<blockquote><p>另一个可能的点:n 个 client 与 m 个 server 之间可能的连接数是 n*m。</p></blockquote></li></ol><p>作者的解法是将 memcached 加上 webserver 一起划成若干个 region 对应同一个 DB,好处:</p><ol><li>异常情况的影响范围小(俗称爆炸范围小)</li><li>可使用不同的网络配置</li></ol><p>不同 region 对应同样的 DB,因此会有数据重复(即 replication),但分散了热点,允许不同的配置,这样用空间换取了其它好处。</p><h2 id="Regional-Invalidations"><a href="#Regional-Invalidations" class="headerlink" title="Regional Invalidations"></a>Regional Invalidations</h2><p>在 region 化之后,数据可能同时存在于多个 region 上,当有 client 更新了对应的 key 之后,我们要能保证所有 region 的 cache 都能被正确 invalidate 掉。</p><p><img src="/images/2022-09/memcached-05.png"></p><p>作者的做法是在每台 DB 上部署一个名为 mcsqueal 的 daemon,它会在事务提交之后,将其中被影响到的 cache keys 取出来,广播给所有业务集群。为了降低 invalidation 的发包速度,daemon 会聚合一组 invalidation 发给部署有 mcrouter 的业务机器,再由这些机器将请求转发给具体的 memcached。</p><p>这种做法,相比于 web server 直接发送 invalidation 给 memcached,好处:</p><ol><li>mcsqueal 的聚合效果更好</li><li>mcsqueal 有机会重发 invalidation(直接通过 DB 的 WAL)。</li></ol><blockquote><p>但如果数据本身是没有 MVCC 的,这种做法仍然有非常小的不一致风险:</p><ol><li>get data v0</li><li>update data to v1</li><li>invalidate data</li><li>fill cache with v0</li></ol></blockquote><h2 id="Regional-Pools"><a href="#Regional-Pools" class="headerlink" title="Regional Pools"></a>Regional Pools</h2><p>如果将用户请求发给随机的一个前端集群,则最终每个集群都会 cache 几乎相同的数据。这样好处是我们可以单独下线一个集群而不会影响 cache 命中率。但另一方面,这会造成数据过度拷贝(over-replicating)。作者的解法是令多个前端集群共享同一组 memcached server,称为一个 regional pool。</p><p>此时有个挑战时,如何决定哪些数据应该在每个 cluster 中都有副本,哪些应该一个 region 只有一份。前者的跨集群流量更少,延时更低,但后者更省机器和内存。</p><p>作者给出的标准是:</p><ol><li>用户越多,越倾向于 cluster</li><li>访问频率越高,越倾向于 cluster</li><li>value 越大,越倾向于 cluster</li></ol><h2 id="Cold-Cluster-Warmup"><a href="#Cold-Cluster-Warmup" class="headerlink" title="Cold Cluster Warmup"></a>Cold Cluster Warmup</h2><p>为了解决集群刚启动时 cache 有效数据极少,命中率极差的问题,冷集群可以从热集群中搬数据过来,而不用直接请求 DB。这样一个集群的预热可以在几小时内完成(相比之前要几天时间)。</p><p>这里要注意的是避免数据不一致。一种常见的不一致场景是,来自从热集群获取数据与收到 invalidation 之间乱序,即先收到 invalidation,再收到 stale data。作者的解法比较 hack:令 invalidation 等待两秒再操作。这样仍然会冒一些数据不一致的风险,但实践中显示两秒是非常安全的。</p><blockquote><p>真的吗,我不信.jpg</p></blockquote><h1 id="Across-Regions-Consistency"><a href="#Across-Regions-Consistency" class="headerlink" title="Across Regions: Consistency"></a>Across Regions: Consistency</h1><p>Facebook 会 region 化部署 MySQL 集群,从而:</p><ol><li>用户可以请求距离最近的 region,从而降低延时</li><li>控制爆炸半径</li><li>新集群往往可以享受到能源或经济上的好处</li></ol><p>MySQL 本身会有一个全局的 master 实例,其它 region 都是只读副本,region 之间使用 MySQL 的 replication 协议保持数据同步。但备实例落后于主实例却容易导致 memcached 与 DB 有数据不一致。</p><p>这种不一致本质上也来自收到数据与收到 invalidation 之间乱序。备集群收到数据是延后的,如果 invalidation 提前到达,就没有指令可以令 stale data 失效了。</p><p>一致性模型往往会带来性能上的额外开销,使其无法应用在大规模集群上。Facebook 的优势是 DB 与 memcached 可以协同设计来在一致性与性能之间取得比较好的平衡。</p><p><strong>master region 发起的写入</strong></p><p>考虑到一个位于 master region 的 webserver 刚写完 DB,它直接发送 invalidation 给各个 master region 的 memcached 是安全的,但发给其它 replica region 就不安全了:对应 region 可能还没有收到相应的修改。</p><p>此时前面引入的 mcsqueal 就起了重要的作用:各个 region 都通过自身的 mcsqueal 来 invalidate 失效数据,保证了每个 region 的 DB 与 cache 状态是一致的。</p><p><strong>非 master region 发起的写入</strong></p><p>现在考虑非 master region 的写请求,如果仍然等 mcsqueal 来 invalidate,就会违反 read-your-write 语义;如果直接 invalidate,就可能导致数据不一致。</p><p>作者引入了 remote marker 来最小化数据不一致的风险。remote marker 可以标记本地的某个 key 可能已失效,需要将请求转发给 master region。注意当有并发写入同一个 key 的情况时,过早删除 remote marker 仍然可能导致用户看到过期数据。</p><h1 id="Single-Server-Improvements"><a href="#Single-Server-Improvements" class="headerlink" title="Single Server Improvements"></a>Single Server Improvements</h1><p>all-to-all 通信模式下,单点就可能成为瓶颈,提升单点性能也因此变得重要。</p><h2 id="Performance-Optimiztions"><a href="#Performance-Optimiztions" class="headerlink" title="Performance Optimiztions"></a>Performance Optimiztions</h2><p>优化的起点是一个使用固定大小 hashtable 的单线程 memcached。第一波优化:</p><ol><li>自动扩容 hashtable。</li><li>引入多线程并用一个 global lock 保护共享结构。</li><li>每个线程单独的 UDP 端口以降低争抢。</li></ol><p>接下来的优化:</p><ol><li>引入细粒度的锁:hit get 提升 2 倍;missed get 提升 1 倍。</li><li>TCP 替换为 UDP:get 提升 13%,multiget 提升 8%。</li></ol><h2 id="Adaptive-Slab-Allocator"><a href="#Adaptive-Slab-Allocator" class="headerlink" title="Adaptive Slab Allocator"></a>Adaptive Slab Allocator</h2><blockquote><p>为啥不直接用 jemalloc,自家的产品?一定是历史原因</p></blockquote><h2 id="The-Transient-Item-Cache"><a href="#The-Transient-Item-Cache" class="headerlink" title="The Transient Item Cache"></a>The Transient Item Cache</h2><p>memcached 支持 ttl,会自动踢掉过期的 key。作者将其改成了 lazy eviction,直到下次访问时才判断过期。这带来了一个新问题:一波短生命期的 key 可能一直待在 LRU list 中直到被踢掉,期间一直在占用着内存。</p><p>作者因此使用了一种混合方法:大多数情况下 lazy evict,对少数短生命期的 key 则 proactively eveit。这些 key 会被放到一个环形 buffer,称为 Transient Item Cache。memcached 每秒会扫描这个 buffer。</p><h2 id="Software-Upgrades"><a href="#Software-Upgrades" class="headerlink" title="Software Upgrades"></a>Software Upgrades</h2><p>为了避免集群升级导致 cache 变冷,memcached 会将数据保存在共享内存中,这样新进程启动之后仍然可以有足够的本地数据。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf">Scaling Memcache at Facebook</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>在 cache coherence 方面经常被人引用的 paper。</p></summary>
</entry>
<entry>
<title>[笔记] Cache Craftiness for Fast Multicore Key-Value Storage</title>
<link href="http://fuzhe1989.github.io/2022/09/19/cache-craftiness-for-multicore-key-value-storage/"/>
<id>http://fuzhe1989.github.io/2022/09/19/cache-craftiness-for-multicore-key-value-storage/</id>
<published>2022-09-19T01:00:39.000Z</published>
<updated>2022-10-17T04:12:25.553Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/2168836.2168855">Cache Craftiness for Fast Multicore Key-Value Storage</a></p></blockquote><p><strong>TL;DR</strong></p><p>大名鼎鼎的 Masstree。</p><span id="more"></span><h1 id="Introduction"><a href="#Introduction" class="headerlink" title="Introduction"></a>Introduction</h1><p>开篇第一句话很酷:“Storage server performance matters”。无论什么系统,单机性能永远是非常关键的。毕竟最好的分布式系统就是不要分布(笑)。</p><blockquote><p>面向多核设计的一些关键因素:</p><ol><li>通常读远多于写,因此优化读的性能要比优化写更关键。</li><li>锁不是万恶之源,争抢才是。纯粹的 lockfree 可能难以实现,实现了性能代价也可能很高。在需要原子更新/读取多值时,细粒度锁往往优于 lockfree。</li><li>从 memory-model 角度理解并发操作,避免使用过强的 coherence(如 serializable)。锁本身意味着 serializable,但如果 acquire/release 就能满足要求,那锁就是过强的。</li><li>“不要共享”和“immutable”都是提升性能的利器。这往往意味着 copy-on-write 要出场了。lockfree 也经常需要结合 copy-on-write 才能实现。但此时需要仔细设计如何处理写写冲突。</li><li>memory stall 已经成为了现代系统的一大性能瓶颈,充分利用 cache 以及 prefetch 是关键。前者意味着良好的数据结构设计,避免跨 cacheline 的原子操作,避免 false-sharing,利用空间局部性;后者则是在主动利用时间局部性。</li></ol></blockquote><p>Masstree 其实也是一个 LSM-like 系统,亮点是它的 in-memory 结构。</p><p>TODO</p><h1 id="System-interface"><a href="#System-interface" class="headerlink" title="System interface"></a>System interface</h1><p>Masstree 有一套典型的 key-value 接口:</p><ul><li>get(k)</li><li>put(k, v)</li><li>remove(k)</li><li>getrange(k, n)</li></ul><p>其中前三个是原子的,getrange 不是。</p><h1 id="Masstree"><a href="#Masstree" class="headerlink" title="Masstree"></a>Masstree</h1><p>Masstree 的特点:</p><ol><li>多核之间共享(区别于不同核访问不同的树)。</li><li>并发结构。</li><li>结合了 B+tree 和 Trie-tree。</li></ol><p>Masstree 直面的三个挑战:</p><ol><li>能高效支持多种 key 的分布,包括变长的二进制的 key,且之间可能有大量相同前缀。</li><li>为了保证高性能和扩展性,Masstree 必须支持细粒度并发,且读操作不能读到被写脏的共享数据。</li><li>Masstree 的布局必须能支持 prefetch 和按 cacheline 对齐。</li></ol><p>后两点被作者称为“cache craftiness”。</p><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Masstree 的大结构是一棵 trie tree。</p><p>ART 中提到 trie tree 相比其它 tree 结构的优点是:</p><ol><li>天然的前缀压缩,不需要在叶子节点保存每个完整的 key,节省空间。</li><li>固定 fanout 的 trie tree 能节省 key 之间的比较开销。另外它按下标寻址的特点也天然适合实现 lockfree。</li></ol><p>Masstree 选择用 trie tree 的理由大体也是这样。但 trie tree 的一个问题是 fanout 很难确定:</p><ol><li>fanout 太小,树的深度太大,查询经历的节点太多,随机访问次数多,性能不高。</li><li>fanout 太大,空间浪费严重。</li></ol><p>ART 的思路是设计多种 node 大小,加上前缀压缩和 lazy expansion 来降低空间浪费。</p><p>Masstree 则使用了另一种思路:选择一个巨大的 fanout(2^64,8 字节),但使用 B+tree 来实现 trie node。这样混搭方案的优点:</p><ol><li>逻辑上仍然是 trie tree,前缀压缩的优点仍然在。</li><li>fanout 足够大,避免树太高。</li><li>物理上使用 B+tree,有效避免空间浪费。</li><li>此时 B+tree 面对的只是单个 trie node 的短 key(不超过 8 字节),可以将 key compare 实现得非常高效。</li></ol><p><img src="/images/2022-09/masstree-01.png"></p><p>可以看到 Masstree 逻辑上分成了若干层,每层都由多个 B+tree 组成。每个 B+tree 的叶子节点除了存储 key 和 value 外,还可能存储指向下一层 B+tree 的指针。</p><p>另外 Masstree 中的 B+tree 内部不会 merge 节点,即 remove key 不会引起 key 的重排。这也是为了避免读路径加锁。</p><p>Masstree 同样使用了 lazy expansion,即只在必要的时候创建新的 B+tree。比如一个 key “01234567AB”,长度已经超过了 8 字节,但只要没有其它 key 和它共享前缀 “01234567”,我们就没必须为了它单独创建一层 B+tree。</p><p>Masstree 相比普通的 B+tree 的一个缺点是 range query 开销更大,一个是要重建 key,另一个是要遍历更多的 layer。</p><h2 id="Layout"><a href="#Layout" class="headerlink" title="Layout"></a>Layout</h2><p><img src="/images/2022-09/masstree-02.png"></p><p>Masstree 中 B+tree 每个节点的 fanout 是 15(精妙的设计),其中所的 border nodes(即 leaf nodes)组成链表以支持 remove 和 getrange。</p><p><code>keyslice</code> 是将长度最多为 8 的 key 编码为一个 <code>uint64</code>(需要保证顺序不变,不足用 0 补齐),这样直接用整数比较代替字符串比较来提升性能。</p><p>注意上图中 border node 有 <code>keylen</code> 字段,但 interrior node 就没有了。直接用 <code>keyslice</code> 比较的话必须带上长度,否则无法区分原本就有的 0 和后补上的 0。但 Masstree 保证所有相同 <code>keyslice</code> 的 key 都位于相同的 border node 上,这样 interrior node 上就不需要保存 <code>keylen</code>,直接比较 <code>keyslice</code> 即可,进一步提升了性能。</p><blockquote><p>相同 <code>keyslice</code> 最多有 10 个不同的 key(长度 0-8,外加一个长度可能超过 8 的 key),而 B+tree 的 fanout 是 15,因此总是可以保证这些 key 都在相同的 border node 上。</p></blockquote><p>每个 border node 上所有 key 超过 8 字节的部分都保存在 <code>keysuffixes</code> 中。根据情况它既可能是 inline 的,也可能指向另一块内存。合理设定 inline 大小能提升一些性能。(但不多,可能是因为超过 8 字节的 key 并没有那么多)</p><p>所有 value 都保存在 <code>link_or_value</code> 中,其中是 value 还是指向下一级 B+tree 的指针是由 <code>keylen</code> 决定的。</p><p>Masstree 在访问一个 node 之前会先 prefetch,这就允许 Masstree 使用更宽的 node 来降低树的高度。实践表明当 border node 能放进 4 个 cacheline 大小(256B)时性能最好,此时允许的 fanout 就是 15。</p><h2 id="Nonconcurrent-modification"><a href="#Nonconcurrent-modification" class="headerlink" title="Nonconcurrent modification"></a>Nonconcurrent modification</h2><p>insert 可能造成自底向上的分裂,但 remove 不会合并节点,只有当某个节点因此变空的时候,整个节点一起删掉。这个过程也是自底向上的。</p><p>所有 border node 之间维护一个双向链表,目的是加速 remove 和 getrange。后者只要求单向链表,但前者的实现依赖双向链表。</p><p>Masstree 有个对尾插入的优化:如果一个 key 插入到当前 B+tree 的尾部(border node 没有 next),且当前 border node 已经满了,则它直接插入到新节点中,老节点的数据不移动。</p><h2 id="Concurrency-overview"><a href="#Concurrency-overview" class="headerlink" title="Concurrency overview"></a>Concurrency overview</h2><p>Masstree 中的并发控制本质上是 MVCC + 读路径乐观锁 + 写路径悲观锁:</p><ol><li>每个节点有一个 <code>version</code> 字段,读请求需要在读节点数据的前后分别获取一次 <code>version</code>,确保 <code>version</code> 不变,从而避免脏读。</li><li><code>version</code> 本身包含 lock 以及细粒度的状态信息,写路径通过悲观锁来解决冲突。</li></ol><p>其中比较困难的是保证 split 和 remove 时读请求仍然能正确地读到数据。</p><h2 id="Writer-writer-coordination"><a href="#Writer-writer-coordination" class="headerlink" title="Writer-writer coordination"></a>Writer-writer coordination</h2><p><img src="/images/2022-09/masstree-03.png"></p><p>所有对 node 的修改都需要先对 node 加锁,例外:</p><ul><li><code>parent</code> 是由 parent node 的锁保护。</li><li><code>prev</code> 是由 prev sibling node 的锁保护。</li></ul><p>这样可以简化 split 时的状态管理:parent node 可以直接修改 children 的 <code>parent</code>;原有的 node 可以直接修改新 split 出来的 node 的 <code>prev</code>。</p><p>split 操作需要同时锁住三个 node:当前 node、parent、next。为了避免死锁,加锁顺序永远是从左向右,从下向上。</p><blockquote><p>这个例子中是先锁 node,再锁 next,再锁 parent。</p></blockquote><p>作者表示曾经对比过不同的并发控制方式,最终决定使用这种细粒度 spinlock 方案。相比之下纯粹使用 CAS 并不会降低 cache 层面的一致性开销。</p><blockquote><p>但使用 Masstree 的应用要自己控制好线程数量,尽量减少 context switch,毕竟使用了 spinlock。</p></blockquote><h2 id="Writer-reader-coordination"><a href="#Writer-reader-coordination" class="headerlink" title="Writer-reader coordination"></a>Writer-reader coordination</h2><p>基本原则:</p><ol><li>一次写操作开始前会修改 <code>version</code>,结束后再修改一次 <code>version</code>。</li><li>读操作开始前会读一次 <code>version</code>,结束后再读一次 <code>version</code>,如果两者不等,说明发生了脏读,需要重试。</li></ol><p>接下来的优化方向是:针对部分写操作避免修改 <code>version</code>;针对部分读操作避免重试。</p><p><img src="/images/2022-09/masstree-04.png"></p><h3 id="Updates"><a href="#Updates" class="headerlink" title="Updates"></a>Updates</h3><p>update 操作会修改已有的 value,需要保证这次修改是原子的。这样不会影响到读操作的正确性,因此也就不需要修改 <code>version</code>。但注意的是写请求不能直接删除一个值,需要用 epoch reclamation 等方法 lazy 回收。</p><h3 id="Border-inserts"><a href="#Border-inserts" class="headerlink" title="Border inserts"></a>Border inserts</h3><p>当插入一个值到 border node 上时,为了避免重排已有的 key-value,同时确保这次插入本身对读请求原子可见,Masstree 使用了一种非常巧妙的方法。</p><p>每个节点的 key 和 value 数组都是 append-only 的,真正的顺序通过 <code>permutation</code> 字段体现。每个 node 的 fanout 是 15,每个元素用 4 位,这样一共是 60 位,再加上 4 位来表示当前有多少个元素,正好可以放进一个 uint64 中。</p><p>完整的插入流程:</p><ol><li>锁住 node</li><li>load <code>permutation</code></li><li>计算得到新的 <code>permutation</code></li><li>append 新的 key-value</li><li>原子写回新的 <code>permutation</code>。直到此时这次插入才对读请求可见。</li><li>释放锁</li></ol><p>这个过程不会出现脏读,因此也不需要修改 <code>version</code>。</p><blockquote><p><code>version</code> 中的 <code>vinsert</code> 不处理 border inserts</p></blockquote><h3 id="New-layers"><a href="#New-layers" class="headerlink" title="New layers"></a>New layers</h3><p>Masstree 会将 layer 的创建推迟到 border node 上两个 key 冲突(回顾前面的 lazy expansion,如果两个 key 映射到相同的 keyslice 上,就意味着需要创建新的 layer 了)。</p><p>因此,创建新 layer 必然意味着某个 border node 的 key 已经存在。针对一个 key 的操作都可以不修改 <code>version</code>。但这里有个特殊情况要处理:我们现在要将它对应的 value 替换为一个新的 B+tree。这就意味着我们要完成两项修改(<code>link_or_value</code> 和 <code>keylen</code>),不可能由一个原子操作完成。</p><p>为了不修改 <code>version</code>(会导致对其它 key 的读操作重试),Masstree 这里引入了一个中间状态:首先将 <code>keylen</code> 修改为 <code>UNSTABLE</code>,接下来修改 <code>link_or_value</code>,之后再将 <code>kenlen</code> 修改为正确的值。读请求如果遇到了 <code>UNSTABLE</code> 需要自行重试。</p><blockquote><p>又是一个要尽量避免 context switch 的地方。</p></blockquote><h3 id="Splits"><a href="#Splits" class="headerlink" title="Splits"></a>Splits</h3><p>重头戏来了。</p><p>split 是对整个 node 的操作,因此需要修改 <code>version</code>(中的 <code>vsplit</code>),这样读操作就能意识到 split,避免脏读。这里的难点在于,修改是发生在写线程中,但检查是发生在读线程,需要正确处理,否则可能会有些修改生效了但没被读请求察觉。</p><p><img src="/images/2022-09/masstree-05.png"></p><p><img src="/images/2022-09/masstree-06.png"></p><p>split 过程中会手递手(hand-over-hand)标记和加锁:节点会自底向上标记为 “splitting” 和加锁。同时读请求会自顶向下检查 <code>version</code>。</p><p>考虑下面的中间节点 B 分裂出 B’ 的场景:</p><p><img src="/images/2022-09/masstree-07.png"></p><ol><li>B 和 B’ 标记为 splitting</li><li>包括 X 在内的一半子节点从 B 迁移到 B’</li><li>A 被锁住,并标记为 inserting</li><li>将 B’ 插入 A</li><li>增加 A 的 <code>vinsert</code>、B 和 B’ 的 <code>vsplit</code>,并依次 unlock B,B’,A(按加锁顺序解锁)</li></ol><blockquote><p>为什么不能按加锁的逆序解锁?似乎也没什么问题</p></blockquote><p>接下来作者探讨了 <code>findborder</code> 是如何与 <code>split</code> 配合保证正确性的。其核心思想就是与 hand-over-hand locking 相对应,<code>findborder</code> 中也要 hand-over-hand validate。对中间节点 A,要先获取 B/B’(取决于 <code>findborder</code> 的调用时机)的 <code>version</code>,再 double check A 并未处于 locked 状态,且这期间 A 并未发生分裂。因为是先获取 B/B’ 的 <code>version</code>,且通过 <code>stableversion</code> 我们知道这个时候 B/B’ 一定未处于 inserting 或 splitting 状态,因此此时要么 B/B’ 在一次 split 开始前,那我们就可以继续往下走;要么在我们获取之后 B/B’ 开始了一次 split,那接下来下一轮迭代时我们检查 B/B’ 的 <code>version</code> 就会 fail。</p><p>注意 Masstree 中 insert 总是原地重试,而 split 则会从 root 开始重试。一个因素是并发 split 比并发 insert 更为罕见(fanout 为 15,因此 insert 频率是 split 的 15 倍),因此可以用开销大一些的实现方式。</p><blockquote><p>另一方面,insert 本身也要比 split 轻量很多,后者需要自底向上改变树的结构。</p></blockquote><p><img src="/images/2022-09/masstree-08.png"></p><p>Border node 的 split 主要靠 border nodes 之间的链表来处理,有以下不变量:</p><ol><li>每个 B+tree 初始只有一个 border node,这个 border node 永远不会被删除,且永远是最左边的 border node(链表头)。</li><li>每个 border node 负责的范围是 [lowkey, highkey),其中 lowkey 永远不变,highkey 会在 split 和 delete 时修改。</li></ol><blockquote><p>注意一旦查找到达了某个 border node,就不再需要返回 root 进行重试了:顺着链表一路往下找就行了。</p></blockquote><h3 id="Removes"><a href="#Removes" class="headerlink" title="Removes"></a>Removes</h3><p>Remove 需要注意的几点:</p><ol><li>不会物理删除 key-value,只会修改 <code>permutation</code>。这一步不需要修改 <code>version</code>。</li><li>但下次 insert 重用已标记删除的位置时,需要修改 <code>vinsert</code> 以避免脏读。</li><li>border node 变为空时会被整个删除,因此我们需要维护一个双向链表来实现 O(1) 的删除节点操作。</li><li>中间节点的删除操作也需要 hand-over-hand locking。</li><li>一个 B+tree 为空时可以删除整个树,但需要确保同时锁上自身和上层指向它的 border node。</li><li>所有删除操作都需要在 epoch reclamation 的保护下进行。</li></ol><h2 id="Values"><a href="#Values" class="headerlink" title="Values"></a>Values</h2><p>Masstree 中需要保证 value 具有原子修改能力。因此大多数情况下 Masstree 中的 value 都会指向一块单独分配的内存,修改时通过 copy-on-write,再替换这个指针。</p><blockquote><p>当然对于直接支持 atomic 的 value 就可以原地修改了。</p></blockquote><h2 id="Discussion"><a href="#Discussion" class="headerlink" title="Discussion"></a>Discussion</h2><p>Masstree 中 lookup 的 30% 的开销来自 node 内部的 key lookup。Masstree 现在是用线性查找,相比二分查找,它的时间复杂度会略高一些,但局部性更好。Masstree 的测试显示在 Intel 处理器上线性查找会快 5% 左右。</p><p>另一个潜在优化是类似于 PLAM 的并行查找,通过重叠内存的 prefetch 来掩盖访问内存的延时,测试显示在 Intel 处理器上会提升多达 34% 的吞吐(但在 AMD 机器上没有什么效果)。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/2168836.2168855">Cache Craftiness for Fast Multicore Key-Value Storage</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>大名鼎鼎的 Masstree。</p></summary>
</entry>
<entry>
<title>[笔记] Processing a Trillion Cells per Mouse Click</title>
<link href="http://fuzhe1989.github.io/2022/09/14/processing-a-trillion-cells-per-mouse-click/"/>
<id>http://fuzhe1989.github.io/2022/09/14/processing-a-trillion-cells-per-mouse-click/</id>
<published>2022-09-14T14:42:31.000Z</published>
<updated>2022-10-17T04:12:25.554Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="http://www.vldb.org/pvldb/vol5/p1436_alexanderhall_vldb2012.pdf">Processing a Trillion Cells per Mouse Click</a></p></blockquote><p><strong>TL;DR</strong></p><p>一篇相对早期(2012 年)的 paper,介绍了服务特定领域(WebUI)的 OLAP 系统 PowerDrill。PowerDrill 貌似在狗家命运不太好,这篇 paper 发表前后已经被 Dremel 给取代了。</p><span id="more"></span><h1 id="Introduction"><a href="#Introduction" class="headerlink" title="Introduction"></a>Introduction</h1><p>PowerDrill 是一个 in-memory 的列存分析引擎。它主要为 Google 的一个交互式的 WebUI 提供分析服务,需要高可用和低延时。因此 PowerDrill 选择了 in-memory 架构,这是它和 Dremel 最大的区别。优点是性能好,单个查询能扫描的行数比 Dremel 多,缺点是能支持的总数据量远小于 Dremel。</p><p>PowerDrill 的一些数据(看看就好):</p><ol><li>2008 年底上线,2009 年面向 Google 内部开放使用。</li><li>每个月有超过 800 名用户(其实不少了)执行约四百万个 SQL query。</li><li>最多的用户每天花在 WebUI 上的时间超过 6 小时,触发多达一万两千次查询,扫描多达 525 万亿(trillion)个 cell。</li></ol><h1 id="Basic-Approach"><a href="#Basic-Approach" class="headerlink" title="Basic Approach"></a>Basic Approach</h1><h2 id="The-Power-of-Full-Scans-vs-Skipping-Data"><a href="#The-Power-of-Full-Scans-vs-Skipping-Data" class="headerlink" title="The Power of Full Scans vs. Skipping Data"></a>The Power of Full Scans vs. Skipping Data</h2><p>列存相对于行存的主要优势是:</p><ol><li>典型查询只需要加载少部分列。(这里提到的是不多于 10 列 vs 上千列,看来是宽表场景)</li><li>列存压缩效率高,更节省 I/O 与内存占用。</li></ol><p>另外列存系统通常会着重优化扫描性能。对于像 PowerDrill 这样的大量 ad-hoc query 的系统,传统的二级索引已经无法满足需求了(维护过多列上的索引的代价是非常巨大的)。</p><p>有一个经验法则,相对于通过索引,全表扫描的优点是:</p><ol><li>随机 I/O 更少。(索引扫描本身是顺序的,但回表是随机 I/O)</li><li>内循环更简单,更容易被优化</li><li>cache 局部性好。这一点对于内存中的数据尤为关键,访问 L2 性能要比内存高一个数量级。</li></ol><p>在全表扫描基础上,我们可以再引入 skip index。这就需要在数据导入时将数据切成若干个小 chunk,每个 chunk 维护简单的数据结构用于在查询时过滤掉整个 chunk。这就需要 skip index 本身不能有 false-negative。</p><p>PowerDrill 中 partition + skip index 的效果远好于传统的索引,因为前者可以适用于所有查询,且不需要存储冗余数据(比如相比于 <a href="/2020/08/13/c-store-a-column-oriented-dbms/">C-Store</a>)。</p><h2 id="Partitioning-the-Data"><a href="#Partitioning-the-Data" class="headerlink" title="Partitioning the Data"></a>Partitioning the Data</h2><p>PowerDrill 的 partition 策略是:</p><ol><li>用户选择一组用于 partition 的列(有序)。</li><li>从一个大 chunk 开始,每次用 partition columns 二分(range partition)将其分成两个小 chunk。</li><li>重复第 2 步直到每个 chunk 行数不超过 5 万行。</li></ol><blockquote><p>缺点是需要用户干预</p></blockquote><p>注意查询时 partition columns 并不会被特殊对待。</p><blockquote><p>但它们本身的 range 会反映到 skip index 上</p></blockquote><h2 id="Basic-Data-Structures"><a href="#Basic-Data-Structures" class="headerlink" title="Basic Data-Structures"></a>Basic Data-Structures</h2><p>PowerDrill 主要面向的是字符串。它使用了 global + local directionary 来编码:</p><ol><li>全局 dict 有序。</li><li>每个 chunk 维护这个 chunk 的 local dict 与 global dict 的映射关系。</li><li>实际数据用 local dict 编码。</li></ol><p>这样的优点是:</p><ol><li>local -> global mapping 本身可以当作 skip index 使用。</li><li>local dict 元素数量少于 global dict,编码长度更短。</li></ol><p><img src="/images/2022-09/powerdrill-01.png"></p><h2 id="How-to-Evaluate-a-Query"><a href="#How-to-Evaluate-a-Query" class="headerlink" title="How to Evaluate a Query"></a>How to Evaluate a Query</h2><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> search_string, <span class="built_in">COUNT</span>(<span class="operator">*</span>) <span class="keyword">as</span> c <span class="keyword">FROM</span> data</span><br><span class="line"><span class="keyword">WHERE</span> search_string <span class="keyword">IN</span> ("la redoute", "voyages sncf")</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> search_string <span class="keyword">ORDER</span> <span class="keyword">BY</span> c <span class="keyword">DESC</span> LIMIT <span class="number">10</span>;</span><br></pre></td></tr></table></figure><blockquote><p>主要在讲怎么用 skip index 加速 <code>IN</code></p></blockquote><h2 id="Basic-Experiments"><a href="#Basic-Experiments" class="headerlink" title="Basic Experiments"></a>Basic Experiments</h2><p>这节先测量基准性能,PowerDrill 完全没 partition 数据,只用本身的列存查询引擎。</p><p>Query 1: top 10 countries</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> country, <span class="built_in">COUNT</span>(<span class="operator">*</span>) <span class="keyword">as</span> c <span class="keyword">FROM</span> data</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> country <span class="keyword">ORDER</span> <span class="keyword">BY</span> c <span class="keyword">DESC</span> LIMIT <span class="number">10</span>;</span><br></pre></td></tr></table></figure><p>Query 2: number of queries and overall latency per day</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="type">date</span>(<span class="type">timestamp</span>) <span class="keyword">as</span> <span class="type">date</span>, <span class="built_in">COUNT</span>(<span class="operator">*</span>),</span><br><span class="line"><span class="built_in">SUM</span>(latency) <span class="keyword">FROM</span> data</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> <span class="type">date</span> <span class="keyword">ORDER</span> <span class="keyword">BY</span> <span class="type">date</span> <span class="keyword">ASC</span> LIMIT <span class="number">10</span>;</span><br></pre></td></tr></table></figure><p>Query 3: top 10 table-names</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> table_name, <span class="built_in">COUNT</span>(<span class="operator">*</span>) <span class="keyword">as</span> c <span class="keyword">FROM</span> data</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> table_name <span class="keyword">ORDER</span> <span class="keyword">BY</span> c <span class="keyword">DESC</span> LIMIT <span class="number">10</span>;</span><br></pre></td></tr></table></figure><p>下面的 CSV 和 record-io 都是行存。</p><p><img src="/images/2022-09/powerdrill-02.png"></p><p>解读:</p><ol><li>I/O 不是瓶颈。</li><li>PowerDrill 的 dict encoding 在 query 1 的 group by 发挥了巨大的作用。<blockquote><p>脚注里写 PowerDrill 会将参与 group by 的多列物化为一列,但这三个 query 似乎没有这种场景。</p></blockquote></li><li>所有数据常驻内存对性能影响巨大。但即使去掉这个假设,PowerDrill 性能仍然达到了 Dremel 的 30 倍以上。</li><li>PowerDrill 使用的未压缩的 dict encoding 占用也和 Dremel 压缩后的体积差不多。</li><li>另外即使有如 <code>date</code> 这样的函数,PowerDrill 仍然可以应用 dict encoding,是因为它默认物化了所有表达式。(笑)</li></ol><h1 id="Key-Optimizations"><a href="#Key-Optimizations" class="headerlink" title="Key Optimizations"></a>Key Optimizations</h1><p>PowerDrill 的第一优化原则就是在内存中容纳尽可能多的数据。</p><p><strong>Partitioning the Data into Chunks</strong></p><p>partition 列为 country 和 table_name,最终产生了 150 个 chunk。</p><p>内存占用:</p><p><img src="/images/2022-09/powerdrill-03.png"></p><p>partitioning 略微增加了一些内存占用,其中 query 2 增加得相对较多是因为 local dict 中 distinct 值更多。</p><p><strong>Optimize Encoding of Elements in Columns</strong></p><p>目前 dict code 还是用 int32 保存,但实际上我们可以根据 NDV 选择合适的 bits。当只有一个 distinct value 时,直接保存 rows n 就可以了。</p><p>下面是自适应使用 1/2/4 字节的 dict code 的优化效果(左边是 chunk dict 占用,右边是总占用):</p><p><img src="/images/2022-09/powerdrill-04.png"></p><p>Query 1 的效果显著得过分了。但 query 3 的总体占用仍然非常高(table_name 有非常多的 distinct value),且主要是 globa dict 占用的。</p><p><strong>Optimize Global-Dictionaries</strong></p><p>global dict 满足以下两个条件:有序;通常公共前缀很长。因此 PowerDrill 用建立在 array 上的 trie tree 来编码 string -> code 的映射,这个方向的内存占用就明显降低了。为了同时也能支持 code -> string 的查询,trie tree 的每个 inner node 取 4 bit 长度的数据,这样一个 node 最多 16 个 child。</p><blockquote><p>code -> string 这段没看懂,实际上我们需要做的是在 trie tree 中维护 count,我猜可能是 node 的 fanout 小一些可以减少 children 占用的空间。</p></blockquote><p>trie encoding 的效果是 table_name 的 global dict 从 67.03MB 降到了 3.37MB,query 3 总占用从 81.31MB 降到了 17.66MB。</p><p><strong>Generic Compression Algorithm</strong></p><p>接下来再应用 Zippy(Snappy 的内部版):</p><p><img src="/images/2022-09/powerdrill-05.png"></p><p>效果很好,但相对 Basic 的优势也减小了。</p><p>为什么前面还要自己搞那些优化:前面的 encoding 不需要解压就可以随机访问。毕竟只有很少量的数据会被用到。</p><p>为了平摊解压本身的开销,PowerDrill 使用了两级结构:数据首先以压缩状态加载到内存中,随后被访问时再转换为解压状态。</p><p><strong>Reordering Rows</strong></p><blockquote><p>我个人非常喜欢的一个优化</p></blockquote><p>接下来,为了帮助 Zippy,PowerDrill 使用了一种优化,将不同行数据按字典序进行排序。这样相近的数据排列在一起,能明显提升压缩效率。这个优化尤其适合与 RLE 配合使用。</p><p><img src="/images/2022-09/powerdrill-06.png"></p><p>最优的排序方式本身是 NP-hard 的,一种比较好的启发式算法是先按 cardinality 增序决定列的相对顺序。</p><p><img src="/images/2022-09/powerdrill-07.png"></p><blockquote><p><a href="/2020/12/08/dremel-a-decade-of-interactive-sql-analysis-at-web-scale/">Dremel</a> 中也提到了这个优化,看起来效果很好。但奇怪的是似乎不太能见到 Google 以外的系统用到这个优化。可能它太偏向离线了?在线系统在 compaction 时也有机会做这类优化。</p></blockquote><h1 id="Distributed-Execution"><a href="#Distributed-Execution" class="headerlink" title="Distributed Execution"></a>Distributed Execution</h1><blockquote><p>比较没意思,略</p></blockquote><h1 id="Extensions"><a href="#Extensions" class="headerlink" title="Extensions"></a>Extensions</h1><p><strong>Complex Expressions</strong></p><p>物化表达式</p><p><strong>Count Distinct</strong></p><p>近似算法而不是精确值</p><p><strong>Other Compression Algorithms</strong></p><p>ZLIB 和 LZO。ZLIB 的压缩率很好,但解压太慢。LZO 的一个变种最终被用于了生产环境。</p><p><strong>Further Optimizing the Global-Dictionaries</strong></p><p>为了避免加载无用的 dict,作者将整个 global dict 分成了若干个 sub-dict,lazy load。另外作者还用到了 Bloom-filter 来进一步避免加载无用的 sub-dict。</p><p><strong>Improved Cache Heuristics</strong></p><p>为了避免单个 query 对 LRU cache 的冲击,PowerDrill 使用了类似于 ARC 的 cache。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="http://www.vldb.org/pvldb/vol5/p1436_alexanderhall_vldb2012.pdf">Processing a Trillion Cells per Mouse Click</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>一篇相对早期(2012 年)的 paper,介绍了服务特定领域(WebUI)的 OLAP 系统 PowerDrill。PowerDrill 貌似在狗家命运不太好,这篇 paper 发表前后已经被 Dremel 给取代了。</p></summary>
</entry>
<entry>
<title>[笔记] Better bitmap performance with Roaring bitmaps</title>
<link href="http://fuzhe1989.github.io/2022/09/11/better-bitmap-performance-with-roaring-bitmaps/"/>
<id>http://fuzhe1989.github.io/2022/09/11/better-bitmap-performance-with-roaring-bitmaps/</id>
<published>2022-09-11T12:35:37.000Z</published>
<updated>2022-10-17T04:12:25.553Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://arxiv.org/pdf/1402.6407.pdf">Better bitmap performance with Roaring bitmaps</a></p></blockquote><p><strong>TL;DR</strong></p><p>本文提出了一种 bitmap 压缩格式 Roaring,它使用自适应的两级索引结构,分别用 bitmap 保存 dense 数据、用数组保存 sparse 数据,由此在空间占用与常见操作性能之间取得了很好的平衡。</p><p>相比 trivial 的 bitset 实现,Roaring 在内存占用,以及超稀疏场景下的操作性能上都有着明显的优势。相比基于 RLE 的 WAH 和 Concise 两种格式,它在空间占用与操作性能上都有着明显的优势。</p><blockquote><p>Roaring 属于是一看就觉得 make sense,早该如此的 idea。</p></blockquote><span id="more"></span><h1 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h1><p>bitmap 属于是超经典数据结构了。我们对 bitmap 的要求有:</p><ol><li>空间占用,可以按 bit per element 来衡量。</li><li>常见操作的性能:<ol><li>union</li><li>intersect</li><li>n-th element</li><li>count</li></ol></li></ol><p>最 trivial 的 bitmap 实现就是各种语言基本都带的 bitset。它的缺点是空间占用与元素密度成反比,当元素非常稀疏时空间占用太大。</p><p>Roaring 之前最常用的高性能 bitmap 实现主要是基于 RLE(run-length encoding)的,如 <a href="https://escholarship.org/content/qt5v921692/qt5v921692.pdf">WAH</a> 和 <a href="https://arxiv.org/pdf/1004.0403">Concise</a>。</p><p><a href="https://escholarship.org/content/qt5v921692/qt5v921692.pdf">WAH</a> 将 n 位的 bitmap 按字长 w(如 32)位分成若干组。每组有两种类型:fill word 与 literal word:</p><ol><li>fill word 指连续若干个 w-1 位都是相同的 0 或者 1,这样的序列用一个 word 表示,其中最高位为 1,次高位为 0 或 1,其余 w-2 位编码序列长度。如序列长度为 2,表示后面 62 位都是 0 或 1。</li><li>literal word 指 w-1 位中既有 0 又有 1,这样的 word 最高位为 0,接下来是 w-1 位数据。</li></ol><p>WAH 的问题是当编码稀疏集合时,如 {0, 2(w-1), 4(w-1), …},平均每个元素要用 2w 位来编码。</p><blockquote><p>第一个 word 要用来编码 0-31 位,其中有一个元素,所以是 literal word;第二个 word 要用来编码 32-61 位,全是 0,所以是 fill word。依次类推,每个元素对应一个 literal word 和一个 fill word,所以是 2w 位。</p></blockquote><p>Concise 则针对这种场景做了一个优化,将空间占用降了一半。它将 fill word 中用于编码 run-length 的部分抽出了 log2(w) 位(w=32 时抽出 5 位)用来编码 p(0<= p < w)。语义是:</p><ol><li>接下来 w-1 位中,只有第 p-1 位是 0/1,其它 w-2 位都是 1/0。</li><li>再后面跟着 r(r 为 run-length)个 fill word。</li><li>如果 p=0,则表示后面 r+1 个都是 fill word。</li></ol><p>这样上面的稀疏集合就可以表示为 n 个 fill word,平均每个元素使用 w 位。</p><blockquote><p>注意 Concise 只是对 w-1 位中有一个特异值的场景做了特殊优化。当有超过 1 个特异值时,这 w-1 位仍然会编码为 literal word。</p></blockquote><p>但这些基于 RLE 的编码都有个问题:随机访问慢。如 n-th element 需要 O(n) 时间。另外在做 union 或 intersection 操作时,如果遇到大段的 0 或 1,WAH 和 Concise 缺乏跳过另一个集合中对应范围的能力(需要跳到某个位置)。</p><blockquote><p>实际上 RLE 编码普遍可以通过额外维护一个 index 来将 n-th element 降到 O(1) 时间。作者也提到了 auxiliary index。这里轻描淡写有点不厚道。</p></blockquote><p>Roaring 的思路来自 RIDBit,两者都是将集合空间 [0, n) 分成若干个 chunk,每个 chunk 根据 dense 或 sparse 分别编码。两者区别在于 RIDBit 用链表来表达 sparse chunk,而 Roaring 则用紧凑的数组。众所周知链表对 cache 是非常不友好的,这点就造成了巨大的性能差异。</p><p>另外 Roaring 也应用了很多新的优化,其中比较重要的是依赖 CPU 的 popcnt 指令来快速计算 cardinality。</p><h1 id="Roaring-Bitmap"><a href="#Roaring-Bitmap" class="headerlink" title="Roaring Bitmap"></a>Roaring Bitmap</h1><p>Roaring 的设计其实非常直接:</p><ol><li>将 32 位整数的值域 [0, n) 分成长度为 2^16(64K)的 chunk。每个 chunk 中所有数字的高 16 位都相同。</li><li>chunk 中的元素数量不多于 4096 时,使用一个 16 位整数数组来保存每个元素的低 16 位。数组保持有序。元素数量多于 4096 时,用一个 2^16 位的 bitmap 表示所有元素。这样,我们总是能够保证平均每个元素占的空间不多于 16 位。</li><li>所有 chunk 的指针保存在一个动态数组中,按元素高 16 位排序。通常这个数组的 chunk 数量会很小,可以保持在 cache 中。</li></ol><p><img src="/images/2022-09/roaring-01.png"></p><p>图中可以看到,每个 chunk 还会记录一些元数据:</p><ol><li>高 16 位</li><li>cardinality</li></ol><p>cardinality 可以用来加速 count、n-th element 等操作,在 union 和 intersection 等操作上也有帮助。</p><p>对于非常稠密的 chunk,如 cardinality >= 2^16-4096,Roaring 还可以将其转换为相反值对应的稀疏 chunk。</p><p>无论是 bitmap 还是 array 类型的 chunk,都还可以进一步应用 bitmap 或 array 上的一些编码方式,进一步降低空间或提升性能。</p><h1 id="Access-Operations"><a href="#Access-Operations" class="headerlink" title="Access Operations"></a>Access Operations</h1><p>向一个 array chunk 插入新元素可能令其 cardinality 超过 4096,此时 Roaring 会将其转换为 bitmap chunk。相反 bitmap chunk 也会因删除元素被转换为 array chunk。</p><blockquote><p>但直接用 4096 作为阈值可能产生颠簸,可能需要再选择一个阈值,比如 8192 作为 array chunk 到 bitmap chunk 的转换阈值。</p><p>array chunk 的插入和删除成本可能会很高(但有界,毕竟 size 不超过 4096)。</p></blockquote><h1 id="Logical-Operations"><a href="#Logical-Operations" class="headerlink" title="Logical Operations"></a>Logical Operations</h1><p>这节主要讲 union 和 intersection 的实现。</p><p>Roaring 中两个集合做 union 或 intersection 时,总是先按高 16 位对齐 chunk,再对相应的两个 chunk 做 union 或 intersection,生成新 chunk(后面也讨论了 in-place 修改)。</p><p>接下来,我们根据两个 chunk 的类型分成三种情况讨论。</p><p><strong>Bitmap vs Bitmap</strong></p><p>每个 bitmap 大小为 2^16 位,因此两个 bitmap 之间的与或操作就等同于 1024 个 64 位整数之间的与或。</p><p><strong>Algorithm 1</strong> Routine to compute the union of two bitmap containers</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">1: input: two bitmaps A and B indexed as arrays of 1024 64-bit integers</span><br><span class="line">2: output: a bitmap C representing the union of A and B, and its cardinality c</span><br><span class="line">3: c ← 0</span><br><span class="line">4: Let C be indexed as an array of 1024 64-bit integers</span><br><span class="line">5: for i ∈ {1, 2, . . . , 1024} do</span><br><span class="line">6: Ci ← Ai OR Bi</span><br><span class="line">7: c ← c + bitCount(Ci)</span><br><span class="line">8: return C and c</span><br></pre></td></tr></table></figure><p>作者表示这里维护 cardinality 的代价并不大,原因:</p><ol><li><code>bitCount</code> 在现代 CPU 上直接映射为一条 <code>popcnt</code>,性能非常高,只需要一个周期。</li><li>对于现代的超标量 CPU,同时间可以有多条没有数据依赖的指令同时执行。上图中的 L6 和 L7 相互没有数据依赖,因此大概率是同时执行的。</li><li>这种简单运算的瓶颈通常是 cache miss,而不是运算能力。</li></ol><p>作者的数据是 Java 下单线程每秒可以运算 7 亿次 64 位整数的或操作。如果加上 L7,吞吐降到了 5 亿次,下降了 30%,但仍然远高于 WAH 和 Concise。</p><p>上面是 union。对于 intersection,Roaring 将 cardinality 提前计算出来。如果新的 cardinality 不超过 4096,就会新生成一个 array,具体算法见 Algorithm 3,其中用 Algorithm 2 加速运算。</p><p><strong>Algorithm 2</strong> Optimized algorithm to convert the set bits in a bitmap into a list of integers. We assume two-complement’s arithmetic. The function bitCount returns the Hamming weight of the integer.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">1: input: an integer w</span><br><span class="line">2: output: an array S containing the indexes where a 1-bit can be found in w</span><br><span class="line">3: Let S be an initially empty list</span><br><span class="line">4: while w != 0 do</span><br><span class="line">5: t ← w AND − w // clear all 1s but the least 1 in w</span><br><span class="line">6: append bitCount(t − 1) to S</span><br><span class="line">7: w ← w AND (w − 1) // clear the least 1</span><br><span class="line">8: return S</span><br></pre></td></tr></table></figure><p><strong>Algorithm 3</strong> Routine to compute the intersection of two bitmap containers. The function bitCount returns the Hamming weight of the integer.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"> 1: input: two bitmaps A and B indexed as arrays of 1024 64-bit integers</span><br><span class="line"> 2: output: a bitmap C representing the intersection of A and B, and its cardinality c if c > 4096 or an equivalent array of integers otherwise</span><br><span class="line"> 3: c ← 0</span><br><span class="line"> 4: for i ∈ {1, 2, . . . , 1024} do</span><br><span class="line"> 5: c ← c + bitCount(Ai AND Bi)</span><br><span class="line"> 6: if c > 4096 then</span><br><span class="line"> 7: Let C be indexed as an array of 1024 64-bit integers</span><br><span class="line"> 8: for i ∈ {1, 2, . . . , 1024} do</span><br><span class="line"> 9: Ci ← Ai AND Bi</span><br><span class="line">10: return C and c</span><br><span class="line">11: else</span><br><span class="line">12: Let D be an array of integers, initially empty</span><br><span class="line">13: for i ∈ {1, 2, . . . , 1024} do</span><br><span class="line">14: append the set bits in Ai AND Bi to D using Algorithm 2</span><br><span class="line">15: return D</span><br></pre></td></tr></table></figure><p><strong>Bitmap vs Array</strong></p><p>intersection:遍历 array 中的每个元素,查询 bitmap。</p><p>union:复制一份 bitmap,再依次将 array 中每个元素插入到 bitmap 中。</p><p><strong>Array vs Array</strong></p><p>union:如果 cardinality 之和不超过 4096,直接 merge 两个数组;否则先将结果写入一个 bitmap,最终如果发现 cardinality 不超过 4096,再将 bitmap 转换回 array(使用 Algorithm 2)。</p><p>intersection:如果两个 array size 差距不那么悬殊(64 倍以上),则直接 merge,否则使用 galloping intersection 算法。</p><p>galloping intersection 是遍历小数组中每个元素,在大数组中二分查找。它要求输入两个数组大小差距非常悬殊。</p><p>以上所有操作也可以 in-place 修改,好处是避免了内存分配和初始化。</p><p>另外,当聚合非常多个 chunk 时,比如 union,我们可以先从中找到一个 bitmap,复制一份出来,再将其它 chunk 都原地 union 到这个复制的 bitmap 上。</p><p><strong>Algorithm 4</strong> Optimized algorithm to compute the union of many roaring bitmaps</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"> 1: input: a set R of Roaring bitmaps as collections of containers; each container has a cardinality and a 16-bit key</span><br><span class="line"> 2: output: a new Roaring bitmap T representing the union</span><br><span class="line"> 3: Let T be an initially empty Roaring bitmap.</span><br><span class="line"> 4: Let P be the min-heap of containers in the bitmaps of R, configured to order the containers by their 16-bit keys.</span><br><span class="line"> 5: while P is not empty do</span><br><span class="line"> 6: Let x be the root element of P. Remove from the min-heap P all elements having the same key as x, and call the result Q.</span><br><span class="line"> 7: Sort Q by descending cardinality; Q1 has maximal cardinality.</span><br><span class="line"> 8: Clone Q1 and call the result A. The container A might be an array or bitmap container.</span><br><span class="line"> 9: for i ∈ {2, . . . , |Q|} do</span><br><span class="line">10: if A is a bitmap container then</span><br><span class="line">11: Compute the in-place union of A with Qi: A ← A OR Qi. Do not re-compute the cardinality of A: just compute the bitwise-OR operations.</span><br><span class="line">12: else</span><br><span class="line">13: Compute the union of the array container A with the array container Qi: A ← A OR Qi. If A exceeds a cardinality of 4096, then it beco mes a bitmap container.</span><br><span class="line">14: If A is a bitmap container, update A by computing its actual cardinality.</span><br><span class="line">15: Add A to the output of Roaring bitmap T.</span><br><span class="line">16: return T</span><br></pre></td></tr></table></figure><h1 id="Experiments"><a href="#Experiments" class="headerlink" title="Experiments"></a>Experiments</h1><p>一图胜千言</p><p><img src="/images/2022-09/roaring-02.png"></p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://arxiv.org/pdf/1402.6407.pdf">Better bitmap performance with Roaring bitmaps</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>本文提出了一种 bitmap 压缩格式 Roaring,它使用自适应的两级索引结构,分别用 bitmap 保存 dense 数据、用数组保存 sparse 数据,由此在空间占用与常见操作性能之间取得了很好的平衡。</p>
<p>相比 trivial 的 bitset 实现,Roaring 在内存占用,以及超稀疏场景下的操作性能上都有着明显的优势。相比基于 RLE 的 WAH 和 Concise 两种格式,它在空间占用与操作性能上都有着明显的优势。</p>
<blockquote>
<p>Roaring 属于是一看就觉得 make sense,早该如此的 idea。</p>
</blockquote></summary>
</entry>
<entry>
<title>[笔记] Order-Preserving Key Compression for In-Memory Search Trees</title>
<link href="http://fuzhe1989.github.io/2022/09/09/order-perserving-key-compression-for-in-memory-search-trees/"/>
<id>http://fuzhe1989.github.io/2022/09/09/order-perserving-key-compression-for-in-memory-search-trees/</id>
<published>2022-09-09T14:53:32.000Z</published>
<updated>2022-10-17T04:12:25.554Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/3318464.3380583">Order-Preserving Key Compression for In-Memory Search Trees</a></p></blockquote><p><strong>TL;DR</strong></p><p>本文提出了一种针对字符串的分段编码框架 HOPE(High-speed Order-Preserving Encoder),在构建初始字典之后,可以流式编码任意字符串。且,重点来了,编码之间仍然保持原有字符串的顺序。这样 HOPE 的适用范围就不仅仅是静态的压缩已有数据了,它还能直接与各种树结构结合,直接用编码后的值作为 key。这样的好处有:</p><ol><li>对于 B-tree 等,更短的 key 意味着更大的 fanout。</li><li>对于 Trie 等,更短的 key 意味着更低的高度。</li><li>节省空间有助于在内存中维护更多数据(如 cache 等)。</li><li>节省空间有助于提升 cache 性能。</li></ol><blockquote><p>令我大开眼界。直觉这篇 paper 比较实用。</p></blockquote><span id="more"></span><h1 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h1><p>现代的内存查找树大致可以分为三类:</p><ol><li>B-tree/B+tree 家族,如 Bw-tree。</li><li>Trie 和各种 radix,如 ART。</li><li>混合型,如 Masstree。</li></ol><p>用在这些树结构上时,通用压缩算法,如 LZ77、Snappy、LZ4 等的问题是需要解压之后才能使用,单次开销大。</p><p>传统的整值字典编码有三个问题:</p><ol><li>可以保持编码值顺序不变的字典编码算法,在处理新增字典值时开销大。</li><li>字典值的查询本身通常也会用到一些树型结构,相比直接用原始值查询并没有优势。</li><li>如果值的 NDV 比较大,字典会越来越大,最终抵消掉空间上的收益。</li></ol><blockquote><p>文章还讨论了基于频率的保序编码,如 DB2 BLU 以及 padding encoding,但我没太看懂。看描述意思前者也还是字典,只不过不同值的编码长度不同。后者在前者基础上做了补 0。这类方法主要用于列存编码中,不太适合内存查找树这种不停有新数据进来的场景,问题应该也是在字典的维护成本上。</p><p>之前没了解过这两种编码,不知道我理解得对不对。</p></blockquote><p>像 Huffman 这样的墒编码会产生变长编码,之前在列存中的问题是解码太慢,但在内存查找树中这就不是问题了:查询和过滤都只针对编码值,不需要解码为原始值。</p><h1 id="Compression-Model"><a href="#Compression-Model" class="headerlink" title="Compression Model"></a>Compression Model</h1><h2 id="The-String-Axis-Model"><a href="#The-String-Axis-Model" class="headerlink" title="The String Axis Model"></a>The String Axis Model</h2><p><img src="/images/2022-09/hope-01.png"></p><p>为了能实现用固定的字典编码任意字符串,HOPE 提出了如下模型:</p><ol><li>将所有字符串按字典序排列在一个数轴上。</li><li>将数轴划分为若干个区间,每个区间的所有字符串会有一个最长共同前缀 S<sub>i</sub>。</li><li>使用某种编码算法为每个区间赋予一个编码值,这也同时是 S<sub>i</sub> 的编码值。</li><li>编码字符串时,根据它落在的区间,将前缀 S<sub>i</sub> 替换为对应的编码值。</li><li>重复步骤 4,直到字符串变成空串。</li></ol><p>通过编码区间,HOPE 因此可以用固定大小的字典匹配任意字符串。但为了能保证编码过程收敛,需要保证每个 S<sub>i</sub> 不能为空,因此每个区间都要保证有一个非空的共同前缀。</p><p><img src="/images/2022-09/hope-02.png"></p><p>以上就是 HOPE 定义的区间的<strong>完备性</strong>。</p><p>注意,不同区间可以有相同的前缀,但它们的编码值不同。</p><p>接下来,问题就是如何让编码保持顺序了。这就需要我们在步骤 3 使用一种保序的编码方式。</p><h2 id="Exploiting-Entropy"><a href="#Exploiting-Entropy" class="headerlink" title="Exploiting Entropy"></a>Exploiting Entropy</h2><blockquote><p>跳过其中出现的所有公式……反正都看不太懂</p></blockquote><p>作者提出了四种类型的编码:</p><ol><li>FIFC,定长区间+定长编码,如 ASCII,作者表示不讨论。</li><li>FIVC,定长区间+变长编码,典型如 Hu-Tucker 编码。Huffman 或算术编码也算,但它们不保序。</li><li>VIFC,变长区间+定长编码,典型如 ALM。优点是解码快。要注意的是因为区间是变长的,Code(ab) != Code(a)+Code(b),但 HOPE 的场景不需要这种性质。</li><li>VIVC,变长区间+变长编码,这是理论上压缩率最优的类型,但之前少有人研究。但这种方式下编码和解码速度都会受变长的影响。</li></ol><p><img src="/images/2022-09/hope-03.png"></p><h2 id="Compression-Schemes"><a href="#Compression-Schemes" class="headerlink" title="Compression Schemes"></a>Compression Schemes</h2><p>文章选择了 6 种编码方式:</p><ol><li>Single-Char,FIVC,元素长度为 1,使用 Hu-Tucker 编码。</li><li>Double-Char,FIVC,元素长度为 2,使用 Hu-Tucker 编码。注意考虑到长度只有 1 的字符串,元素里需要有 b∅ 这样的占位符。</li><li>ALM,VIFC,它的特点是会根据 len(s) × freq(s) 判断 s 是否放进字典中。当所有满足条件的 s<blockquote><p>大概知道个意思,具体细节还得看对应 paper。</p></blockquote></li><li>3-Grams,VIVC,所有区间都用长度为 3 的字符串隔开,仍然用 Hu-Tucker 编码(看起来 VC 都是用 Hu-Tucker 编码)。</li><li>4-Grams,VIVC,区间边界长度为 4。</li><li>ALM-Improved,VIVC。相比 ALM 改进两点:<ol><li>使用 Hu-Tucker 生成变长编码。</li><li>ALM 会统计所有长度、所有位置的子串的频率,构建速度慢,内存占用高。ALM-Improved 则只统计每个采样字符串的所有前缀子串。</li></ol></li></ol><p><img src="/images/2022-09/hope-04.png"></p><h1 id="HOPE"><a href="#HOPE" class="headerlink" title="HOPE"></a>HOPE</h1><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p><img src="/images/2022-09/hope-05.png"></p><p>HOPE 分为以下组件:</p><ol><li>Symbol Selector(负责划分区间和取前缀)</li><li>Code Assigner(负责赋予编码)</li><li>Dictionary(构建字典)</li><li>Encoder(使用字典编码字符串)</li></ol><p>其中字典大小对于 VI 模式是可配的。</p><h2 id="Implementation"><a href="#Implementation" class="headerlink" title="Implementation"></a>Implementation</h2><p><strong>Symbol Selector</strong></p><p>ALM 和 ALM-Improved 的 Symbol Selector 需要额外一步来保证每个采样的值不能是另一个值的前缀,否则这两个值会落入相同区间,如 sig 和 sigmod。方法是总是将前缀串对应的频率加到它最长的延长串上。</p><p>对于 VI 编码,Symbol Selector 还会用对应的 symbol(共同前缀)长度为其对应的频率加权。</p><p><strong>Code Assigner</strong></p><p>Hu-Tucker 编码和 Huffman 非常像,也是统计出每个元素的频率,然后每次取出两个频率最低的拼在一起放回,依次直到所有元素组成一棵树,再沿着树的路径用 01 为每个叶子节点赋值。</p><p>与 Huffman 的区别在于,Hu-Tucker 每次必须取两个相邻的、频率之和最低的元素,放回时也保证顺序。</p><p><strong>Dictionary</strong></p><p>字典中只需要保存每个区间的左边界。</p><p>HOPE 中实现了三种不同的字典结构。</p><p><img src="/images/2022-09/hope-06.png"></p><p>Single-Char 和 Double-Char 直接使用了数组。</p><p>3-Grams 和 4-Grams 使用了一种 bitmap-tree,有点类似于 Trie,用一个 bitmap 来保存具体的子节点是否存在。因此 bitmap 长度为 256。每个节点保存 (n, bitmap),n 是它之前所有节点中的 1 的个数。所有节点保存在一个大数组中。这样一个节点的字符 l 对应的节点在数组中的下标就是 n + popcount(bitmap, l)。现代 CPU 都直接有 popcount 的指令,因此这种寻址方式非常快。</p><p><img src="/images/2022-09/hope-07.png"></p><p>最后,ALM 和 ALM-Improved 使用 ART 来保存字典。</p><blockquote><p>没看过 ART paper,这段略过。</p></blockquote><p><strong>Encoder</strong></p><p>编码过程就是不停地判断字符串落在哪个区间,然后将前缀替换为编码,反复直到字符串变空。</p><p>为了加速编码的拼接,HOPE 用若干个 uint64 来保存结果,每当有新编码接入时:</p><ol><li>左移当前结果以留出空间。这步可能需要分裂原编码结果。</li><li>用按位或写入新编码。</li></ol><p>当批量编码时,HOPE 将值分成若干个固定数量的组,对每个组每次取共同前缀,只编码一次。当组内元素数量为 2 时,称为 pair-encoding。</p><blockquote><p>后面的 evaluation 部分略。看完感觉 3-Grams 和 4-Grams 真是好编码。</p></blockquote>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/3318464.3380583">Order-Preserving Key Compression for In-Memory Search Trees</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>本文提出了一种针对字符串的分段编码框架 HOPE(High-speed Order-Preserving Encoder),在构建初始字典之后,可以流式编码任意字符串。且,重点来了,编码之间仍然保持原有字符串的顺序。这样 HOPE 的适用范围就不仅仅是静态的压缩已有数据了,它还能直接与各种树结构结合,直接用编码后的值作为 key。这样的好处有:</p>
<ol>
<li>对于 B-tree 等,更短的 key 意味着更大的 fanout。</li>
<li>对于 Trie 等,更短的 key 意味着更低的高度。</li>
<li>节省空间有助于在内存中维护更多数据(如 cache 等)。</li>
<li>节省空间有助于提升 cache 性能。</li>
</ol>
<blockquote>
<p>令我大开眼界。直觉这篇 paper 比较实用。</p>
</blockquote></summary>
</entry>
<entry>
<title>[笔记] InfiniFS: An Efficient Metadata Service for Large-Scale Distributed Filesystems</title>
<link href="http://fuzhe1989.github.io/2022/09/07/infinifs-an-efficient-metadata-service-for-large-scale-distributed-filesystems/"/>
<id>http://fuzhe1989.github.io/2022/09/07/infinifs-an-efficient-metadata-service-for-large-scale-distributed-filesystems/</id>
<published>2022-09-07T15:01:51.000Z</published>
<updated>2022-10-17T04:12:25.554Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://www.usenix.org/system/files/fast22-lv.pdf">InfiniFS: An Efficient Metadata Service for Large-Scale Distributed Filesystems</a></p></blockquote><p><strong>TL;DR</strong></p><p>InfiniFS 针对的是如何实现超大规模的单一分布式文件系统,目标上有些类似于 Facebook 的 Tectonic。但 InfiniFS 仍然是比较正统的、遵守 POSIX 语义的 fs,而 Tectonic 则是 HDFS 的升级版,目的是解决 Facebook 自身业务遇到的实际问题。</p><p>InfiniFS 看上去是 LocoFS 的后继,延续了 <a href="/2022/09/07/locofs-a-loosely-coupled-metadata-service-for-distributed-file-systems/">LocoFS</a> 将 metadata 分成两部分的设计(但针对所有 inode 而不只是 f-inode)。另外 InfiniFS 还从 <a href="https://fuzhe1989.github.io/2022/09/07/hopsfs-scaling-hierarchical-file-system-metadata-using-mysql-databases/">HopsFS</a> 借鉴了并发 load inode。除此之外 InfiniFS 还有如下独特设计:</p><ol><li>client 端可以通过 hash 预测 inode id。结合并发 load inode,可以有效降低 network trip。</li><li>client 与 metadata server 共同维护的一致性 cache。</li><li>单独的 rename coordinator。</li></ol><blockquote><p>整体看下来感觉 InfiniFS 的完成度还是比较高的,很实用,可能和有阿里云的前同事参与有比较大的关系。</p></blockquote><span id="more"></span><h1 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h1><p>超大文件系统的挑战:</p><ol><li>data partitioning 难以兼顾良好的局部性和负载均衡性。<ol><li>相近的文件/目录经常被集中访问,造成热点。</li><li>如果 hash partition,则分散了热点,但降低了局部性,同样的操作(如 path resolution/list dir)涉及更多节点。</li><li>如果以子树为单位 partition,则与之相反。</li></ol></li><li>路径深度变大,path resolution 延时增加。<ol><li>Trivial 的按 dir id partition(Tectonic 再次出镜) 会导致长度为 N 的路径查找需要经历 N-1 个 trip。</li></ol></li><li>client 端 cache 的一致性维护负担加大。<ol><li>lease 机制(如 <a href="/2022/09/07/locofs-a-loosely-coupled-metadata-service-for-distributed-file-systems/">LocoFS</a>)会导致越接近 root 的节点被 renew 的频率越高,无形中制造了热点。<blockquote><p>在 metadata server 本身分布式之后,lease 维护代价也会变高。</p></blockquote></li></ol></li></ol><p>但超大文件系统的好处:</p><ol><li>全局 namespace 允许全局数据共享。</li><li>资源利用率高(Tectonic 也有讲)(集中力量办大事)。</li><li>避免了跨系统操作,降低了复杂度。</li></ol><p>下图是生产环境采集到的操作数量统计(很有用):</p><p><img src="/images/2022-09/infinifs-01.png"></p><ol><li>File 操作占了 95.8%。</li><li>readdir 占了 93.3% 的目录操作。</li><li>dir rename 和 set_permission 非常罕见。</li></ol><h1 id="Design-and-Implementation"><a href="#Design-and-Implementation" class="headerlink" title="Design and Implementation"></a>Design and Implementation</h1><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p><img src="/images/2022-09/infinifs-02.png"></p><p>InfiniFS 有多个 metadata server,分别服务不同的 metadata partition。另外有一个全局唯一的 rename coordinator,处理 dir rename 与 set_permission。</p><h2 id="Access-Content-Decoupled-Partitioning"><a href="#Access-Content-Decoupled-Partitioning" class="headerlink" title="Access-Content Decoupled Partitioning"></a>Access-Content Decoupled Partitioning</h2><p>类似于 <a href="/2022/09/07/locofs-a-loosely-coupled-metadata-service-for-distributed-file-systems/">LocoFS</a>,但区别在于:前者将 f-inode 分成了 access 和 content 两部分,而 InfiniFS 则是将所有 inode 分成了 access 和 content 两部分。</p><p><img src="/images/2022-09/infinifs-04.png"></p><p>其中 access 按 parent id partition,而 content 则按自身 id partition。这样每个 inode 的 access 就会与其 parent 的 content 分在相同的 server 上。</p><p><img src="/images/2022-09/infinifs-03.png"></p><p>这样 create/delete/readdir 等操作就只需要访问一个节点(它们的特点是需要 parent 的 content 和 child 的 access)。由此 InfiniFS 既分散了热点,又保证了一定的局部性。</p><blockquote><p>注意 path resolution 仍然需要经历 N-1 个节点。</p></blockquote><h2 id="Speculative-Path-Resolution"><a href="#Speculative-Path-Resolution" class="headerlink" title="Speculative Path Resolution"></a>Speculative Path Resolution</h2><h3 id="Predictable-Directory-ID"><a href="#Predictable-Directory-ID" class="headerlink" title="Predictable Directory ID"></a>Predictable Directory ID</h3><p><img src="/images/2022-09/infinifs-05.png"></p><p>InfiniFS 使用了一种 hash 算 inode id 的方法:<code>id = hash(parent_id, name, name_version)</code>,其中 name_version 是一个由 parent 维护的 counter,用来保证 id 的唯一性。</p><p>规则:</p><ol><li>每个 dir 维护一个 rename list(RL),其中每个元素都是 <code>(name, version)</code>,且 version 各不相同。初始 dir 的 name_version 为 0。此时 RL 为空。</li><li>每个 dir 在被 rename 之后,会在自身 inode 中记录一个 back pointer(BP),指向它<strong>被创建时</strong>的 parent,和自己<strong>被第一次 rename</strong> 的时候的 name_version。</li><li>每当有 rename 发生,所在的 dir 的 name_version 就增加。</li><li>每当有 id 冲突,name version 也增加。</li><li>存在 BP 的 dir 在被删除后,通过它的 BP 将 RL 中对应项删掉,它创建时的 dir 的 name_version 也可能因此减小。</li><li>某个 dir 被删除时,它对应的 RL 会直到变空才删除。</li></ol><p>这些规则保证了:</p><ol><li>id 唯一性:通过 name_version 处理 hash 冲突。</li><li>client 大概率可以直接根据 path 计算出每个 inode id。</li><li>被 rename 的 dir 不需要更换 id(后续在它的 birth dir 创建的相同 name 的 child 不会有相同的 name_version)。</li></ol><p>同时我们知道 dir rename 的概率是非常低的,因此 name version 的维护开销也预期非常低。</p><blockquote><p>但 name version 如果保持单调增的话,也有好处,就是一个 id 几乎永远不会被重用。当然考虑到不是 100% 保证,意义不那么大。</p></blockquote><h3 id="Parallel-Path-Resolution"><a href="#Parallel-Path-Resolution" class="headerlink" title="Parallel Path Resolution"></a>Parallel Path Resolution</h3><p><img src="/images/2022-09/infinifs-06.png"></p><blockquote><p>直接参考 <a href="https://fuzhe1989.github.io/2022/09/07/hopsfs-scaling-hierarchical-file-system-metadata-using-mysql-databases/">HopsFS</a></p></blockquote><h2 id="Optimistic-Access-Metadata-Cache"><a href="#Optimistic-Access-Metadata-Cache" class="headerlink" title="Optimistic Access Metadata Cache"></a>Optimistic Access Metadata Cache</h2><p><img src="/images/2022-09/infinifs-07.png"></p><p>这块要先讲 InfiniFS 中 dir rename 的实现。</p><p>InfiniFS 中会有全局唯一的 rename coordinator 负责所有的 dir rename,过程如下:</p><ol><li>检查 rename 之间不会冲突(经典的 rename 造成孤儿 entry)。之后为每个 rename op 赋一个单调增的 version number。</li><li>锁住对应的 dir,确保 rename 期间不会有请求访问对应的<strong>子树</strong>。</li><li>广播 rename,invalidate 所有相关的 cache。</li><li>真正执行 rename。</li></ol><p>这里的广播是指向所有 metaserver 广播。每个 metaserver 会本地记录所有 rename op,但注意 metaserver 本身是没有 cache 的。</p><p>client 会在 cache 中记录每个 inode 对应的 version number,之后与 metaserver 通信时也会带上这个 version。metaserver 如果发现对应的 inode 发生过 rename,则会将 client 发来的 version 之后所有的相关 rename 都返回给 client。</p><p>这样通过 version number,我们就可以保证 client 总是会看到一致的 cache,但同时又不需要同步 invalidate。</p><p>当然上面过程也要求 rename coordinator 与 metaserver 都要记录 WAL 等信息到本地磁盘上。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://www.usenix.org/system/files/fast22-lv.pdf">InfiniFS: An Efficient Metadata Service for Large-Scale Distributed Filesystems</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>InfiniFS 针对的是如何实现超大规模的单一分布式文件系统,目标上有些类似于 Facebook 的 Tectonic。但 InfiniFS 仍然是比较正统的、遵守 POSIX 语义的 fs,而 Tectonic 则是 HDFS 的升级版,目的是解决 Facebook 自身业务遇到的实际问题。</p>
<p>InfiniFS 看上去是 LocoFS 的后继,延续了 <a href="/2022/09/07/locofs-a-loosely-coupled-metadata-service-for-distributed-file-systems/">LocoFS</a> 将 metadata 分成两部分的设计(但针对所有 inode 而不只是 f-inode)。另外 InfiniFS 还从 <a href="https://fuzhe1989.github.io/2022/09/07/hopsfs-scaling-hierarchical-file-system-metadata-using-mysql-databases/">HopsFS</a> 借鉴了并发 load inode。除此之外 InfiniFS 还有如下独特设计:</p>
<ol>
<li>client 端可以通过 hash 预测 inode id。结合并发 load inode,可以有效降低 network trip。</li>
<li>client 与 metadata server 共同维护的一致性 cache。</li>
<li>单独的 rename coordinator。</li>
</ol>
<blockquote>
<p>整体看下来感觉 InfiniFS 的完成度还是比较高的,很实用,可能和有阿里云的前同事参与有比较大的关系。</p>
</blockquote></summary>
</entry>
<entry>
<title>[笔记] HopsFS: Scaling Hierarchical File System Metadata Using NewSQL Databases</title>
<link href="http://fuzhe1989.github.io/2022/09/07/hopsfs-scaling-hierarchical-file-system-metadata-using-mysql-databases/"/>
<id>http://fuzhe1989.github.io/2022/09/07/hopsfs-scaling-hierarchical-file-system-metadata-using-mysql-databases/</id>
<published>2022-09-07T05:01:10.000Z</published>
<updated>2022-10-17T04:12:25.553Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://www.usenix.org/system/files/conference/fast17/fast17-niazi.pdf">HopsFS: Scaling Hierarchical File System Metadata Using NewSQL Databases</a></p></blockquote><p><strong>TL;DR</strong></p><p>HopsFS 目标是成为下一代 HDFS,其核心改进是使用一个分布式的 NewSQL 系统(NDB)替代了 HDFS 原本的单节点 in-memory metadata management。</p><p>亮点:</p><ol><li>展示了如何使用分离的存储系统来管理 metadata。</li><li>仔细设计 schema 以缓解分布式事务对性能的影响。</li><li>并行 load inode。</li></ol><span id="more"></span><p>NDB:</p><ol><li>可以指定 partition 规则。</li><li>执行事务时可以根据 hint 选择最多数据所在的 node 作为 coordinator 从而降低跨机流量。</li><li>只支持 read-committed,但支持行级别锁。</li></ol><p><img src="/images/2022-09/hopsfs-01.png"></p><p>HopsFS 中有多个 namenode,其中会选出一个 leader,但所有 namenode 都可以执行 metadata 操作。</p><p>HopsFS 的 metadata partition 规则:</p><ol><li>inode 根据 parent id partition。</li><li>file content metadata 根据 file id partition。</li></ol><blockquote><p>file content metadata 和 file inode 使用不同的 partition 规则,这样 listdir + open 可能会相对低效。</p></blockquote><p>这种设计下越靠近 root 的 inode 越热,为了分散热点:</p><ol><li>root 默认 immutable,因此可以到处 cache。</li><li>允许前几级目录(默认 2)根据自身 name(而不是 parent id)hash partition,这样分散压力。层级越多,热点越不明显,但 rename 和 ls 的性能下降越厉害。</li></ol><p>NDB 只支持 read-committed,但作为一个 fs,HopsFS 需要提供 serializability,因此需要结合行锁进行操作。为了避免死锁:</p><ol><li>规定所有锁的获取按固定顺序,即在目录树中从左向右广度优先。</li><li>所有锁必须在事务开始前确定是读锁还是写锁,避免过程中锁升级导致的死锁。</li></ol><p>HopsFS 中 path resolution 并不一开始就在事务中,而是先以 read-committed 拿到前面所有部分,直到最后一部分才需要上锁(某些情况下也要锁住 parent)</p><blockquote><p>过程中如果有多个祖先节点被不同的事务修改,HopsFS 中可能看到不一致的状态。但 POSIX 没有规定这种情况下该保证什么样的一致性,因此也没问题。比如 create /a/b/c/d 的时候有人 mv /a /b,mv /a/b /a/c,那么 create 看到的 /a/b/c/d 可能实际上是 /b/b/c/d 或者 /a/c/c/d 或者 /b/c/c/d,但总归文件会被创建在 d 下面。</p></blockquote><p>HopsFS 自身会维护一个 hierarchical lock,即锁某个节点也意味着锁住对应的子树。否则无法保证 serializability。</p><p>在锁住子树之后,HopsFS 还需要确保子树中没有操作正在进行,方法是对子树中每个节点加锁再解锁。</p><blockquote><p>操作有点重……</p></blockquote><p>事务过程中如果有 namenode 宕机,它已经加过的锁就需要由别的 namenode 后续清除,方法是查看锁的 owner 是否还存活(类似于 percolator)。</p><p>此外,事务 commit 阶段写 NDB 的时候即使中途失败,HopsFS 也要保证数据本身是一致的。看上去 NDB 没有 rollback 机制,因此 HopsFS 需要仔细设计操作顺序,比如 remove 子树需要从底向上 post-order 执行,这样保证了结果总是一致的。</p><p>最后是 HopsFS 的一个亮点:并行 load inode。</p><p>HopsFS 中 client 会缓存每个 path component 对应的 inode。当 resolve path 的时候,HopsFS 会并发拿 cache 中的每个 inode id 去 load,如果遇到 cache miss,再退化为递归式的 path resolution。这样在 fast path 下可以将 network trip 从 N 降到 1。只有 remove 和 rename 会使得 cache 失效。实际场景下退化的概率不到 2%。</p><p><img src="/images/2022-09/hopsfs-02.png"></p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://www.usenix.org/system/files/conference/fast17/fast17-niazi.pdf">HopsFS: Scaling Hierarchical File System Metadata Using NewSQL Databases</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>HopsFS 目标是成为下一代 HDFS,其核心改进是使用一个分布式的 NewSQL 系统(NDB)替代了 HDFS 原本的单节点 in-memory metadata management。</p>
<p>亮点:</p>
<ol>
<li>展示了如何使用分离的存储系统来管理 metadata。</li>
<li>仔细设计 schema 以缓解分布式事务对性能的影响。</li>
<li>并行 load inode。</li>
</ol></summary>
</entry>
<entry>
<title>[笔记] LocoFS: A Loosely-Coupled Metadata Service for Distributed File Systems</title>
<link href="http://fuzhe1989.github.io/2022/09/07/locofs-a-loosely-coupled-metadata-service-for-distributed-file-systems/"/>
<id>http://fuzhe1989.github.io/2022/09/07/locofs-a-loosely-coupled-metadata-service-for-distributed-file-systems/</id>
<published>2022-09-07T03:22:53.000Z</published>
<updated>2022-10-17T04:12:25.554Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/3126908.3126928">LocoFS: A Loosely-Coupled Metadata Service for Distributed File Systems</a></p></blockquote><p><strong>TL;DR</strong></p><p>LocoFS 的出发点:</p><ol><li>降低 metadata 操作的网络延时。</li><li>充分发挥 kv-store 的性能。</li></ol><p>LocoFS 做了以下两种优化:</p><ol><li>将 dir entry 与 child 的 inode 放到一起管理,更有利于将整个 namespace 的树型结构平坦化为 key-value 结构。</li><li>将 file metadata 分为 access 与 content 两类,降低单次操作的粒度。</li></ol><p>LocoFS 整体上来看对系统限制还是比较强(限制只有一个 DMS),但思路还不错。</p><span id="more"></span><h2 id="Motivation"><a href="#Motivation" class="headerlink" title="Motivation"></a>Motivation</h2><p><img src="/images/2022-09/locofs-01.png"></p><p>作者发现越来越多的 fs 开始用 kv-store 来保存 metadata,但远没能发挥出 kv-store 的性能,原因:</p><ol><li>根本矛盾是将树型结构映射到平坦的 key-value 结构上导致单个操作需要经过多个节点,此时性能瓶颈是过高的网络延时(trip 过多)。</li><li>kv 的序列化/反序列化成本过高。如果 fs 自己维护一个 cache(如 IndexFS)就与 kv-store 的 cache 重复,反倒浪费了内存带宽(cache 两次)。</li></ol><h2 id="Design-and-Implementation"><a href="#Design-and-Implementation" class="headerlink" title="Design and Implementation"></a>Design and Implementation</h2><h2 id="Loosely-Coupled-Architecture"><a href="#Loosely-Coupled-Architecture" class="headerlink" title="Loosely-Coupled Architecture"></a>Loosely-Coupled Architecture</h2><p><img src="/images/2022-09/locofs-02.png"></p><p>LocoFS metadata 架构包括:</p><ol><li>单个 DMS(Directory Metadata Server)</li><li>若干个 FMS(File Metadata Server)</li></ol><p>其中 DMS 必须是一个的原因应该是更容易实现 rename。DMS 会在 kv-store 中维护 path -> d-inode,意味着 rename 需要修改整个 subtree 的所有节点。单个 DMS 可以很高效地完成这项工作,而多个 DMS 管理成本就会非常高。</p><p>DMS 中 path -> inode 维护为了一棵 B+ 树,这样 rename 本身只涉及其中一个中间节点的移动:</p><p><img src="/images/2022-09/locofs-05.png"></p><blockquote><p>这个设计很赞。另外它也是 LocoFS 的树的平坦化的核心,即将树结构藏到 DMS 中,才能让别的部分保持平坦 kv 结构。但这种架构下 DMS 很容易成为瓶颈,很难支持很高的规模。</p></blockquote><p>DMS 维护的映射:</p><ul><li>path -> d-inode</li><li>dir_uuid -> [dir-entry, dir-entry, …] (所有 children 的 dir-entry 拼成一个 value)</li></ul><p>f-inode 会根据 dir_uuid + file_name 一致性 hash 到某个 FMS 上。</p><p>FMS 维护的映射:</p><ul><li>(dir_uuid + file_name) -> f-inode</li><li>某个 FMS 上所有 file 的 dir-entry 会拼在一起(是吧?)</li></ul><p><img src="/images/2022-09/locofs-03.png"></p><blockquote><p>这里没看懂 DMS 和 FMS 拼接 dir-entry 的好处是什么。</p><p>原文:In the DMS, all the subdirs in a dir have their dirents concatenated as one value, which is indexed by the dir_uuid key. All the files that are mapped to the same FMS have their dirents concatenated and indexed.</p></blockquote><p>此外每个 d-inode 和 f-inode 中也会保存指向 parent 的 dir-entry,这样类似于倒排索引,结合 path 映射,可以有效分担 parent 的压力。</p><p>这套架构上 DMS 的压力会比较大,所以 client 一侧也会积极 cache d-inode。为了保持一致性,所有 client 会保持与 DMS 的 lease。</p><h2 id="Decoupled-File-Metadata"><a href="#Decoupled-File-Metadata" class="headerlink" title="Decoupled File Metadata"></a>Decoupled File Metadata</h2><p><img src="/images/2022-09/locofs-04.png"></p><p>LocoFS 根据用途将 file metadata 分成了 access 与 content 两部分,大多数操作只需要访问其中一部分,这样明显降低了 kv-store 的访问压力。</p><p>另外,为了降低序列化/反序列化开销,LocoFS 将 file metadata 组织为所有字段都定长的 struct,直接将 struct 本身作为 value 写进去而不需要序列化;读某个字段时也不需要反序列化。</p><blockquote><ol><li>缺点是牺牲了一些压缩的可能性(但对 metadata 压缩意义可能不大)</li><li>有些类似于使用 flatbuffers。</li><li>另外这里看起来很适合 PAX style 的 store:schema 固定;每列的数据高度相似;经常只需要更新少量字段。</li></ol></blockquote><p>此外,LocoFS 也没有保存 file 的 block 映射表,而是直接用 file_uuid + block_num 作为 block 的 key 去访问对象存储。</p><blockquote><p>缺点是 truncate 需要是同步的(甚至需要持久化),否则会读到已失效的 block。</p></blockquote>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://dl.acm.org/doi/pdf/10.1145/3126908.3126928">LocoFS: A Loosely-Coupled Metadata Service for Distributed File Systems</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>LocoFS 的出发点:</p>
<ol>
<li>降低 metadata 操作的网络延时。</li>
<li>充分发挥 kv-store 的性能。</li>
</ol>
<p>LocoFS 做了以下两种优化:</p>
<ol>
<li>将 dir entry 与 child 的 inode 放到一起管理,更有利于将整个 namespace 的树型结构平坦化为 key-value 结构。</li>
<li>将 file metadata 分为 access 与 content 两类,降低单次操作的粒度。</li>
</ol>
<p>LocoFS 整体上来看对系统限制还是比较强(限制只有一个 DMS),但思路还不错。</p></summary>
</entry>
<entry>
<title>C++: std::initializer_list 的类型推导</title>
<link href="http://fuzhe1989.github.io/2022/08/26/cpp-initializer-list-type-infer/"/>
<id>http://fuzhe1989.github.io/2022/08/26/cpp-initializer-list-type-infer/</id>
<published>2022-08-26T09:21:29.000Z</published>
<updated>2022-09-02T12:07:26.879Z</updated>
<content type="html"><![CDATA[<p><a href="https://godbolt.org/z/nh57noeM3">Compiler Explorer</a> 一个例子胜千言。</p><p>更准确地说明看<a href="https://en.cppreference.com/w/cpp/utility/initializer_list">这里</a>。</p><span id="more"></span><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">auto</span> l = {<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>};</span><br><span class="line"><span class="built_in">static_assert</span>(std::is_same_v<<span class="keyword">decltype</span>(l)::value_type, <span class="type">int</span>>);</span><br></pre></td></tr></table></figure><p>直接用 <code>auto</code> 接收一个 <code>std::initializer_list<T></code>,可以,且能正确推导类型。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">std::vector<<span class="type">int</span>> v1 = {<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>};</span><br></pre></td></tr></table></figure><p>直接拿它初始化确定的类型,可以。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">auto</span> v2 = std::<span class="built_in">vector</span>({<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>});</span><br><span class="line"><span class="built_in">static_assert</span>(std::is_same_v<<span class="keyword">decltype</span>(v2), std::vector<<span class="type">int</span>>>);</span><br></pre></td></tr></table></figure><p>直接用 <code>auto</code> 接收一个推导 <code>std::initializer_list<T></code> 进而初始化的 <code>std::vector<T></code>,可以,且能正确推导类型。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> T></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">g</span><span class="params">(std::initializer_list<T> input)</span> </span>{</span><br><span class="line"> std::vector<T> v = input;</span><br><span class="line"> std::cout << v.<span class="built_in">size</span>() <<std::endl;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="built_in">g</span>({<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>});</span><br></pre></td></tr></table></figure><p>直接匹配模板函数的类型参数,可以。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> T></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f</span><span class="params">(std::vector<T> input)</span> </span>{</span><br><span class="line"> std::cout << input.<span class="built_in">size</span>() <<std::endl;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="built_in">f</span>({<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>});</span><br></pre></td></tr></table></figure><p>直接匹配 <code>std::vector<T></code> 参数的模板,不行!</p>]]></content>
<summary type="html"><p><a href="https://godbolt.org/z/nh57noeM3">Compiler Explorer</a> 一个例子胜千言。</p>
<p>更准确地说明看<a href="https://en.cppreference.com/w/cpp/utility/initializer_list">这里</a>。</p></summary>
</entry>
<entry>
<title>[笔记] Anti-Caching: A New Approach to Database Management System Architecture</title>
<link href="http://fuzhe1989.github.io/2022/08/25/anti-caching-a-new-approach-to-database-management-system-architecture/"/>
<id>http://fuzhe1989.github.io/2022/08/25/anti-caching-a-new-approach-to-database-management-system-architecture/</id>
<published>2022-08-25T04:04:25.000Z</published>
<updated>2022-09-02T12:07:26.879Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://dl.acm.org/doi/pdf/10.14778/2556549.2556575">Anti-Caching: A New Approach to Database Management System Architecture</a></p></blockquote><p><strong>TL;DR</strong></p><p>Anti-Caching 是基于 H-Store 实现的 in-memory db。作者认为传统的 RDBMS 是以磁盘为主存,内存为缓存(cache),而 in-memory db 则应该反过来,内存才是主存,磁盘只是用于 swap,数据不会同时存在于两个地方,可以认为“没有 cache”,因此称其为 “anti-caching”。</p><span id="more"></span><p>事务仍然继承了 H-Store “只支持存储过程”的设计,每个 partition 严格按事务 ID 顺序串行执行,因此不需要死锁检测。</p><p>每个 partition 使用一个 LRU list 记录所有内存中的 tuple。为了降低开销,作者引入了抽样,只有被抽样的事务才会真正去修改 LRU list。</p><p>不在内存中的 tuple 会被记录在 evicted table 中,其中每个 tuple 会记录所属的 blockid 与 offset。</p><p>Block 的 metadata 常驻内存,它的 value 部分也是尽量贴近内存中的数据格式(比如可能没有压缩?)。</p><p>事务执行过程中,如果遇到 evicted tuples,就会进入 pre-pass 状态。这个状态下,事务会继续执行完,但在结束时会 rollback 自己的所有改动,然后开始加载遇到的所有 evicted tuples,事务自身重新 enqueue,等待第二次执行。某些情况下这个过程会重复多次(比如依赖 evicted value 做过滤),但作者表示很罕见,可以接受。</p><p>Anti-caching 保证事务重新被运行之前它需要的这些 tuples 都会被加载好,且会 pin 在内存中(所以事务间存在内存容量的争抢)。</p><p>Evicted tuples 的加载是以 block 为粒度的,当加载好之后,它会被当作一个事务 enqueue,这样确保更改 tuple table 的时候不会有事务在执行(回忆 H-Store 事务是串行执行的)。这里有两种 merge 策略:</p><ul><li>block-merge,将整个 block 的所有 tuple 都 merge 进 tuple table,其中被需要的 tuple 放到 LRU 的尾,其它顺带加载的放到 LRU 头。这种策略的问题在于开销比较大(尤其是如果只有非常少量 tuple 需要读)。另外它对 tuple table 的冲击也比较大,可能导致不停的换入换出。</li><li>tuple-merge,只 merge 需要的 tuple。这样 tuple 就可能同时存在于内存和磁盘中(但作者表示问题不大)。另一个可能的问题是磁盘上的 block 就相当于存在空洞,因此 anti-caching 有一种 lazy-compaction,当 block 的空洞足够大的时候,直接将这个 block 的剩余部分加载上来。</li></ul><p>分布式事务:依赖于单个 partition pin 住内存,等到所有参与的 partition 都加载好再一起执行。(聊胜于无)</p><p>Snapshot & Recovery:略。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://dl.acm.org/doi/pdf/10.14778/2556549.2556575">Anti-Caching: A New Approach to Database Management System Architecture</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>Anti-Caching 是基于 H-Store 实现的 in-memory db。作者认为传统的 RDBMS 是以磁盘为主存,内存为缓存(cache),而 in-memory db 则应该反过来,内存才是主存,磁盘只是用于 swap,数据不会同时存在于两个地方,可以认为“没有 cache”,因此称其为 “anti-caching”。</p></summary>
</entry>
<entry>
<title>[笔记] High Performance Transactions in Deuteronomy</title>
<link href="http://fuzhe1989.github.io/2022/08/24/high-performance-transactions-in-deuteronomy/"/>
<id>http://fuzhe1989.github.io/2022/08/24/high-performance-transactions-in-deuteronomy/</id>
<published>2022-08-24T04:42:00.000Z</published>
<updated>2022-09-02T12:07:26.879Z</updated>
<content type="html"><![CDATA[<blockquote><p>原文:<a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/DeuteronomyTC-CIDR2015-full.pdf">High Performance Transactions in Deuteronomy</a></p></blockquote><p><strong>TL;DR</strong></p><p>Deuteronomy 终于进化到了 MVCC 加持。新的 Deuteronomy 仍然是 TC + DC 的分离架构,但做了大量的优化,如 Bw-Tree、caching、lock-free 算法/数据结构、epoch reclamation 等。</p><blockquote><p>大多数系统其实不是架构不行,而是单纯优化不到位。</p></blockquote><p>总得来说我们能从 Deuteronomy 上看到很多后来的 cloud database 的影子。</p><span id="more"></span><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p><img src="/images/2022-08/deuteronomy-01.png"></p><p><img src="/images/2022-08/deuteronomy-02.png"></p><p><img src="/images/2022-08/deuteronomy-03.png"></p><p><img src="/images/2022-08/deuteronomy-04.png"></p><p>Deuteronomy 有三种组件:</p><ul><li>Transaction Component(TC):请求入口,维护 transaction、mvcc table、log buffer、read cache。</li><li>Data Component(DC):存储状态机。</li><li>TC Proxy:将 TC 的 committed transaction 发给 DC。</li></ul><p>Deuteronomy 的核心思想是 TC 与 DC 分离,这样就可以为多种不支持事务的 DC 增加事务能力。但它的<a href="/2021/04/22/deuteronomy-transaction-support-for-cloud-data/">旧实现</a>严重制约了它的性能,尤其是当 DC 经过几次演进之后,使用了 <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/bw-tree-icde2013-final.pdf">Bw-tree</a>、<a href="/2021/05/08/llama-a-cache-storage-subsystem-for-modern-hardware/">LLAMA</a> 等高性能结构之后,原有的 TC 的性能就成为了瓶颈。</p><p>这篇 paper 就是在讲如何重新设计实现 TC(包括新加入的 TC Proxy),使其能充分发挥 DC 的高性能。</p><blockquote><p>全文看完,感想:</p><ul><li>TC 和 DC 分离的思路是非常适合分布式环境的。比如 FoundationDB 就用了类似的思路实现了非常好的扩展性和性能。</li><li>TC 本身看上去是个单点,如何确保它的高可用是个复杂的问题。</li><li>MVCC 的粒度是 record,当数据量非常大的时候是个问题。不同领域可以考虑用不同的粒度,比如传统的 RDBMS 可以用 page,OLAP 可以用 chunk 或者 partition 等。这里不同粒度需要 TC 和 DC 共同确定。</li><li>大量读时,TC 本身可能成为瓶颈,在保证语义的前提下 bypass TC,直接访问 DC,可能是另一大优化。</li></ul><p>这篇 paper 架构层面没有做大的改变,对我而言主要价值来自:</p><ul><li>并发控制方面,为何选择用 TO 而不是 OCC。</li><li>如何在实践中贯彻 latch-free,真正发挥硬件的能力。</li><li>如何通过区分 fast path 和 slow path 兼顾性能与正确性。</li><li>手把手教你怎么实现 epoch reclamation。</li></ul></blockquote><h2 id="TC"><a href="#TC" class="headerlink" title="TC"></a>TC</h2><p>旧的 TC 的事务采用了 2PL,lock 开销很大。新 TC 引入了类似于 [Hakaton] 的 MVCC,其特点是每个 record 对应一个 valid version range。另外每个事务是可以读到其它未提交事务的修改的,因此需要在 commit 时确保所有依赖的事务都 commit 了,否则就要级联式 rollback。</p><p>TC 中与 MVCC 有关的有(见图 2):</p><ul><li>MVCC table,一个无锁的 hash-table,其中每个 item 不保存具体数据,而是指向其它结构。</li><li>Version manager:<ul><li>Log buffer:所有 redo log records,其中包含已经持久化的 records,但仍然保留在内存中用作 cache。</li><li>Read cache:从 DC 读到的 records,与 log buffer 可能有重叠。</li></ul></li></ul><p>注意 TC 只使用了 redo log,而没有 undo log,是因为只有 committed transaction 才会被发往 TC Proxy,最终持久化到 DC 上。在 TC recovery 过程中,没有对应 committed record 的 transaction 会被直接丢弃。因为不需要 undo,redo log 中不需要保存旧值(因此也不需要读取旧值),进一步降低了开销。</p><p><a href="/2021/04/22/deuteronomy-transaction-support-for-cloud-data/">旧设计</a> 中先写 DC 再写 log,因此 DC 还要处理 rollback。DC 还要确保不能将 TC 还未标记 stable 的 log 写下去,由此还引入了 EOSL(End Of Stable Log)机制,由 TC 来精细控制 DC 何时刷盘。新设计明显简化了 DC 的职责,也减少了 RPC 次数。</p><p>另外新设计中 DC 写不再位于关键路径,用户请求只在 log 成功持久化到 TC 之后就返回。</p><p>作者表示新的 TC 性能提升了两个数量级,主要归功于:</p><ol><li>MVCC</li><li>readonly transaction 的 fast path</li><li>延迟向 DC 发送 log</li><li>将 log buffer 用作 cache</li><li>batch 发送 log</li><li>redo log 不需要读取旧值</li><li>新的 latch-free 结构(buffer 管理与内存回收)</li></ol><h2 id="Concurrency-Control"><a href="#Concurrency-Control" class="headerlink" title="Concurrency Control"></a>Concurrency Control</h2><p>新设计的 MVCC 相比 <a href="/2021/05/18/hekaton-sql-servers-memory-optimized-oltp-engine/">Hekaton</a> 的 snapshot isolation 更进一步:serializability。作者选用了 Timestamp Order(TO),理由:</p><ol><li>Hyper 的经验表明 TO 对短事务效果非常好。</li><li>只要过程中严格保证 ts 顺序,TO 在事务 commit 时不需要做任何额外检查(不会有冲突)。(但如前所述,可能存在级联 rollback)</li></ol><p>TC 上所有事务的状态保存在了 transaction table 中。TC 定期会计算得到当前最老的活跃事务 OAT(oldest active transaction),用于垃圾回收。</p><p>MVCC table 是用无锁的 hash-table 实现的,分成了若干个 bucket,其中每个 item 对应一个 record,定长且对应一个 cacheline,包含:</p><ul><li>定长的 hash</li><li>定长的 key 指针(key 本身是变长的)</li><li>访问过当前 record 的最高的 ts</li><li>record 对应的一个或多个 version,其中每个 version 也是定长的,包含:<ul><li>对应的 transaction id</li><li>version offset,对应到 log buffer 或 read cache</li><li>aborted 位</li></ul></li></ul><p>整个 MVCC table 是无锁的,新增 record 就是 append 到对应 bucket 的末尾,而移除则只是设置一个 remove bit,后续再遍历 list,结合 epoch reclamation 真正物理移除。</p><p><a href="/2021/04/22/deuteronomy-transaction-support-for-cloud-data/">旧设计</a> 中只读事务也要在 log buffer 中占一个位置,走完整的 commit 流程才能返回,这显然无法支撑大量读。新设计中作者使用了如下优化:</p><p>只读事务过程中记录访问过的事务对应的最高的 LSN,到它自己 commit 时,如果这个 LSN 已经持久化好了,只读事务就不用再真正将 commit record 加到 log buffer 中了;反之,则仍然要走完整的 commit 流程。这是一种优化 fast path 的思路。</p><p>接下来,MVCC table 需要定期 gc:</p><ul><li>比 OAT 依赖的事务还老的 record 可以回收。</li><li>已经持久化到 DC 的 record 可以回收。</li></ul><h2 id="Managing-Versions"><a href="#Managing-Versions" class="headerlink" title="Managing Versions"></a>Managing Versions</h2><p>TC 处理事务的写操作时,首先通过 MVCC check 是否满足 TO 要求,接下来直接在 log buffer 中分配一个 item,然后再将这个 item 的 offset 和 version 一起写入 MVCC table。这样就能避免数据被反复移动。</p><p>log buffer 本身可以用作 cache,除此之外,read cache 存放 DC 返回的数据。两者共同实现 TC 的 caching 机制。</p><p>read cache 的结构见图 3,它由两个 latch-free 的结构组成:一个 hash table,一个 ring buffer。hash table 可以与 log buffer 共用相同的 id 机制,间接指向 ring buffer 中的某个位置。read cache 的 ring buffer 会随着写入逐渐覆盖老 version(间接达到 LRU 的效果)。所以 hash table 只能当作 hint。</p><p>一种潜在优化是比较热的 version 不应该被直接覆盖,而是应该重新加到 tail,给它第二次机会,但作者表示没有动力做。</p><h2 id="TC-Proxy"><a href="#TC-Proxy" class="headerlink" title="TC Proxy"></a>TC Proxy</h2><p>TC Proxy 永远与 DC 部署在一起。TC 会定期将持久化好的 log 发给 TC Proxy(如果 TC 与 DC 在相同机器上,只会发送引用,避免拷贝)。</p><p>注意 TC 向 TC Proxy 发送的 log 是包含未 committed 的事务的,因此 TC Proxy 需要维护 transaction table。TC Proxy 会定期扫描自身的 log buffer,将所有确定 committed log records 写入 DC,剩余结果未知的 log records 则转移到一个 side buffer(预期很少),这样整个 log buffer 可以重用,效率更高。</p><p>如前所述,Deuteronomy 只有 redo log,不需要读取旧值,因此 TC Proxy 只通过 upsert 向 DC 写入数据。这还允许 DC 中的 Bw-tree 直接向仍在磁盘上的 page 应用 delta(而不需要预先加载到 cache 中)。</p><p>在配合 TC 回收 MVCC records 上,TC Proxy 会维护两个 LSN:</p><ul><li>T-LSN:TC Proxy 已收到的最高 LSN。</li><li>O-LSN:DC 已连续持久化的最高 LSN。</li></ul><p>通过维护两个 LSN,我们可以确保长事务不会阻塞 gc:所有 LSN <= O_LSN 且 commit LSN <= T_LSN 的 MVCC records 都可以被 gc。</p><blockquote><p>commit LSN <= T_LSN 说明 TC Proxy 已知事务已 committed,可能是指此时可以放心去读 DC?</p></blockquote><h2 id="Supporting-Mechanisms"><a href="#Supporting-Mechanisms" class="headerlink" title="Supporting Mechanisms"></a>Supporting Mechanisms</h2><p>作者提到,他们一开始实现 latch-free 时,主要依赖 CAS(compare-and-swap) 操作,但后来换成了 FAA(fetch-and-add)。这也是这一领域的共识:尽量用总是成功的 FAA 替代可能冲突严重的 CAS,从而提升多核下的并发性能。</p><p>具体到 buffer 的 offset 上,作者用低 32 位维护 offset,高 32 位维护当前活跃的 user count:每次 FAA 2^32 + SIZE 来修改 active user count。</p><p>FAA 的结果如果超出 buffer capacity,说明这个 buffer 已经被 seal 了,不能再使用。那个恰好令它超过 capacity 的线程负责 flush buffer。其它发现 buffer 被 seal 的线程则尝试分配新 buffer(一次 CAS,只有一个能成功)。</p><p>接下来是 epoch reclamation。<a href="/2021/05/08/llama-a-cache-storage-subsystem-for-modern-hardware/">LLAMA</a> 和 <a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/bw-tree-icde2013-final.pdf">Bw-tree</a> 中已经介绍了 epoch reclamation 的实现。作者在这里又应用了 thread-local 来优化性能,减少争抢。</p><p>具体实现:</p><ul><li>全局有一个 global epoch,一个固定长度的 buffer。</li><li>每当 buffer size 超过阈值(比如 capacity/4)就会提升 global epoch。</li><li>所有需要删除的对象和它所属的 epoch 一起打包扔进一个固定长度的 buffer。</li><li>每个线程维护 thread-local 的 epoch,每个操作前复制 global epoch 到 tls,操作后将其设置为 NULL。</li><li>维护一个 min tls epoch,每当 global epoch 提升或阻碍了 buffer 回收时就重新计算。</li></ul><p>这样昂贵的操作(计算 min tls epoch)和全局争抢(提升 global epoch)都被分摊了,主要路径只有很少的原子操作。</p><p>线程管理方面 Deuternomy 主要考虑了 NUMA 和 cache 亲和性,尽量避免跨 NUMA 通信。另外 Deuternomy 使用了协程,且优化了内存分配,确保协程栈分配在栈上,而不是堆上。</p>]]></content>
<summary type="html"><blockquote>
<p>原文:<a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/DeuteronomyTC-CIDR2015-full.pdf">High Performance Transactions in Deuteronomy</a></p>
</blockquote>
<p><strong>TL;DR</strong></p>
<p>Deuteronomy 终于进化到了 MVCC 加持。新的 Deuteronomy 仍然是 TC + DC 的分离架构,但做了大量的优化,如 Bw-Tree、caching、lock-free 算法&#x2F;数据结构、epoch reclamation 等。</p>
<blockquote>
<p>大多数系统其实不是架构不行,而是单纯优化不到位。</p>
</blockquote>
<p>总得来说我们能从 Deuteronomy 上看到很多后来的 cloud database 的影子。</p></summary>
</entry>
<entry>
<title>记:不能依赖 std::function 的 move 函数清空 source</title>
<link href="http://fuzhe1989.github.io/2022/08/14/cpp-libcxx-function-copy-first-when-move-with-sso/"/>
<id>http://fuzhe1989.github.io/2022/08/14/cpp-libcxx-function-copy-first-when-move-with-sso/</id>
<published>2022-08-14T03:23:45.000Z</published>
<updated>2022-08-14T03:24:00.373Z</updated>
<content type="html"><![CDATA[<p><strong>TL;DR</strong></p><p>分享某位不愿透露姓名的耿老板发现的 libc++ 的某个奇怪行为:<code>std::function</code> 当内部成员体积足够小,且其 copy 函数标记为 <code>noexcept</code> 时,move ctor 或 assign 函数会优先调用内部成员的 copy 函数,而不是 move 函数。</p><p>这不是 bug,但很反直觉。</p><span id="more"></span><h2 id="问题"><a href="#问题" class="headerlink" title="问题"></a>问题</h2><p>看下面这段代码,你觉得它的输出该是什么</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">auto</span> holder = std::<span class="built_in">make_shared</span><T>(...); <span class="comment">// holder holds some resource</span></span><br><span class="line"><span class="comment">// ...</span></span><br><span class="line">std::function<<span class="type">void</span>()> f1 = [holder = std::<span class="built_in">move</span>(holder)] {};</span><br><span class="line"><span class="keyword">auto</span> f2 = std::<span class="built_in">move</span>(f1);</span><br><span class="line">std::cout << <span class="string">"f1 is "</span> << (f1? <span class="string">"non-empty"</span> : <span class="string">"empty"</span>) << std::endl;</span><br></pre></td></tr></table></figure><p>直觉告诉我应该是 empty,但这是真的吗?</p><p>从 <a href="https://godbolt.org/z/6E71GoK3x">Compiler Explorer</a> 我们看到,在不同编译器下,有不同结果:</p><ul><li>clang + libc++:empty</li><li>clang + libstdc++:non-empty</li><li>gcc:non-empty</li><li>msvc:non-empty</li></ul><p>说明问题出在 libc++ 的实现上。</p><h2 id="影响"><a href="#影响" class="headerlink" title="影响"></a>影响</h2><p>下面是为什么耿老板突然对这个行为产生了兴趣。</p><p>这个问题的影响是:如果我们依赖 <code>std::function</code> 来控制某个对象的生命期,则在后续 move 这个 <code>std::function</code> 之后,必须要手动 clear 或者析构旧的 <code>std::function</code>,不能依赖 move 本身的行为。</p><p>显然,某些代码不是这么写的。</p><h2 id="不是-bug"><a href="#不是-bug" class="headerlink" title="不是 bug"></a>不是 bug</h2><p>虽然非常反直觉(毕竟 <code>std::shared_ptr<T></code> 是 non-trivial 的),但这并不是 bug,因为标准没有规定 move 一个 <code>std::function</code> 之后,旧对象该如何处理:</p><blockquote><ul><li><code>function( function&& other );</code>(since C++11)(until C++20) (4)</li><li><code>function( function&& other ) noexcept;</code> (since C++20) (4)</li></ul><p>3-4) Copies (3) or moves (4) the target of other to the target of *this. If other is empty, *this will be empty after the call too. For (4), other is in a valid but unspecified state after the call. <a href="https://en.cppreference.com/w/cpp/utility/functional/function/function">cppreference</a></p></blockquote><p>“other is in a valid but unspecified state after the call.”</p><p>但只有 libc++ 这么做,仍然很让人难受。</p><h2 id="libc"><a href="#libc" class="headerlink" title="libc++"></a>libc++</h2><p>libc++ 里对应的代码在<a href="https://github.com/llvm/llvm-project/blob/main/libcxx/include/__functional/function.h#L414-L420">这里</a>。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="built_in">sizeof</span>(_Fun) <= <span class="built_in">sizeof</span>(__buf_) &&</span><br><span class="line"> is_nothrow_copy_constructible<_Fp>::value &&</span><br><span class="line"> is_nothrow_copy_constructible<_FunAlloc>::value)</span><br><span class="line">{</span><br><span class="line"> __f_ = ::<span class="built_in">new</span> ((<span class="type">void</span>*)&__buf_) _Fun(</span><br><span class="line"> _VSTD::<span class="built_in">move</span>(__f), _Alloc(__af));</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>可以看到,当初始化一个 <code>__value_func</code> 时,如果对应的 <code>_Fp</code> 足够小,且它和它对应的 allocator 的 copy ctor 都是 <code>noexcept</code>,<code>__value_func</code> 会将 <code>__f_</code> 直接分配在内部 buffer 中。</p><p><a href="https://github.com/llvm-mirror/libcxx/blob/master/include/functional#L1810-L1814">这里</a>则说的是 <code>__value_func</code> 的 move 函数对于 <code>__f_</code> 直接分配在内部 buffer 的这种情况,直接调用了实际 functor 的 <code>__clone</code>,但在之后没有对被 move 的对象做任何清理。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> ((<span class="type">void</span>*)__f.__f_ == &__f.__buf_)</span><br><span class="line">{</span><br><span class="line"> __f_ = __as_base(&__buf_);</span><br><span class="line"> __f.__f_->__clone(__f_);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>这其实是 libc++ 的一种 SOO(small object optimization),或称 SSO(small string optimization)或 SBO(small buffer optimization)。</p><p><a href="https://github.com/llvm/llvm-project/issues/32472">std::function copies movable objects when is SOO is used</a> 解释了 libc++ 不想改掉这个行为是因为需要增加 <code>__clone_move</code> 而破坏 ABI 兼容性。</p><h2 id="进一步测试"><a href="#进一步测试" class="headerlink" title="进一步测试"></a>进一步测试</h2><p>下面这个例子(<a href="https://gcc.godbolt.org/z/YM3csqPKz">Compiler Explorer</a> )验证了我们的观点:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">Test</span> {</span><br><span class="line"> <span class="built_in">Test</span>() {}</span><br><span class="line"> ~<span class="built_in">Test</span>() {}</span><br><span class="line"></span><br><span class="line"> <span class="built_in">Test</span>(<span class="type">const</span> Test&) {}</span><br><span class="line"> <span class="built_in">Test</span>(Test && l) <span class="keyword">noexcept</span> {}</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">struct</span> <span class="title class_">TestNoExcept</span> {</span><br><span class="line"> <span class="built_in">TestNoExcept</span>() {}</span><br><span class="line"> ~<span class="built_in">TestNoExcept</span>() {}</span><br><span class="line"></span><br><span class="line"> <span class="built_in">TestNoExcept</span>(<span class="type">const</span> Test&) <span class="keyword">noexcept</span> {}</span><br><span class="line"> <span class="built_in">TestNoExcept</span>(Test && l) <span class="keyword">noexcept</span> {}</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> T></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">test</span><span class="params">(<span class="type">const</span> std::string &name)</span> </span>{</span><br><span class="line"> T t1;</span><br><span class="line"> std::function<<span class="type">void</span>()> f1 = [t = std::<span class="built_in">move</span>(t1)]() -> <span class="type">void</span>{ <span class="built_in">printf</span>(<span class="string">"lambda\n"</span>); };</span><br><span class="line"> <span class="keyword">auto</span> f2 = std::<span class="built_in">move</span>(f1);</span><br><span class="line"> fmt::<span class="built_in">print</span>(<span class="string">"{} move {}\n"</span>, name, f1 == <span class="literal">nullptr</span>);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="built_in">test</span><Test>(<span class="string">"Test"</span>);</span><br><span class="line"> <span class="built_in">test</span><TestNoExcept>(<span class="string">"TestNoExcept"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>输出为:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Test move true</span><br><span class="line">TestNoExcept move false</span><br></pre></td></tr></table></figure><p><code>Test</code> 和 <code>TestNoExcept</code> 唯一的区别就在于它们 copy ctor 是不是 <code>noexcept</code>。而这就使得后续的 <code>std::function</code> 的行为产生了区别。真是神奇。</p><p>接下来,我们给 <code>TestNoExcept</code> 增加一些体积,使得它不满足 SOO:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">TestNoExcept</span> {</span><br><span class="line"> <span class="built_in">TestNoExcept</span>() {}</span><br><span class="line"> ~<span class="built_in">TestNoExcept</span>() {}</span><br><span class="line"></span><br><span class="line"> <span class="built_in">TestNoExcept</span>(<span class="type">const</span> Test&) <span class="keyword">noexcept</span> {}</span><br><span class="line"> <span class="built_in">TestNoExcept</span>(Test && l) <span class="keyword">noexcept</span> {}</span><br><span class="line"></span><br><span class="line"> <span class="type">char</span> padding[<span class="number">32</span>];</span><br><span class="line">};</span><br></pre></td></tr></table></figure><p>输出就变成了:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">Test move true</span><br><span class="line">TestNoExcept move true</span><br></pre></td></tr></table></figure><p>done。</p>]]></content>
<summary type="html"><p><strong>TL;DR</strong></p>
<p>分享某位不愿透露姓名的耿老板发现的 libc++ 的某个奇怪行为:<code>std::function</code> 当内部成员体积足够小,且其 copy 函数标记为 <code>noexcept</code> 时,move ctor 或 assign 函数会优先调用内部成员的 copy 函数,而不是 move 函数。</p>
<p>这不是 bug,但很反直觉。</p></summary>
</entry>
<entry>
<title>记:C++20 coroutine 的诡异 bug 调查过程</title>
<link href="http://fuzhe1989.github.io/2022/08/09/cpp-coroutine-misalign-frame-address/"/>
<id>http://fuzhe1989.github.io/2022/08/09/cpp-coroutine-misalign-frame-address/</id>
<published>2022-08-09T12:48:56.000Z</published>
<updated>2022-08-12T13:09:19.892Z</updated>
<content type="html"><![CDATA[<p><strong>TL;DR</strong></p><p>C++20 coroutine 有一个严重的 <a href="https://github.com/llvm/llvm-project/issues/56671">bug</a>,且这个 bug 本质上来源于 C++ 标准不完善:在分配 coroutine frame 时,没有严格按 alignment 要求。目前看起来 gcc 与 clang 都中招了,只有 msvc 似乎没问题。</p><p>本文记录了我是如何被这个 bug 消耗掉了<del>两</del>三天光明。</p><span id="more"></span><h2 id="起"><a href="#起" class="headerlink" title="起"></a>起</h2><p>我们项目中使用了 clang + folly::coro。我有一个 benchmark 工具大概长这个样子:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">folly::<span class="function">coro::Task<<span class="type">void</span>> <span class="title">run</span><span class="params">()</span> </span>{</span><br><span class="line"> Config config;</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> IOWorker worker;</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> Runners runners;</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="keyword">co_await</span> runners.<span class="built_in">run</span>();</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>{</span><br><span class="line"> folly::coro::<span class="built_in">blockingWait</span>(<span class="built_in">run</span>());</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>本来运行得很好。这天我需要拿它去给其他同学做个演示,就加了行输出,顺手 rebase 到 main(感恩 gitlab 的 zzzq),就坏事了:一运行就报错,说 <code>Config</code> 中的一个 <code>std::mutex</code> 默认构造时遇到了空指针。</p><p>现场大概长这样:</p><p><img src="/images/2022-08/cpp-coroutine-bug-01.png"></p><p>看到这个报错位置的时候我是有点<code>地铁老人手机.jpg</code>的。</p><p>一定是 rebase 惹的祸,main 的新代码有毒!</p><h2 id="承"><a href="#承" class="headerlink" title="承"></a>承</h2><h3 id="怀疑-TDengine"><a href="#怀疑-TDengine" class="headerlink" title="怀疑 TDengine"></a>怀疑 TDengine</h3><p>查看了一下 main 的最新提交,只是引入了 TDengine,但我的工具没有用到它,只是被动链接了 TDengine client 的静态库。</p><p>会不会是它的静态库改变了某些编译期行为呢?</p><p>先搞清楚 <code>__GTHREAD_MUTEX_INIT</code> 是什么吧。我们虽然使用了 clang,但标准库还是用的 libstdc++,在这里 grep 找到它实际指向 <code>PTHREAD_MUTEX_INITIALIZER</code>,而后者是以宏的形式初始化一个 <code>pthread_mutex_t</code>。</p><p>恰好,我们在 TDengine 代码中找到了它重新定义了这个宏:</p><p><a href="https://github.com/taosdata/TDengine/blob/e8a6b6a5a1e4806ce29ca9f80fe7059eb9ab0730/deps/pthread/pthread.h#L699">#define PTHREAD_MUTEX_INITIALIZER</a></p><p>会不会是这里不小心修改了标准库的行为,进而导致了进程 crash 呢?</p><p>我们随后发现不是:</p><ol><li>初始化一个 c 的 struct 不会因为值而 crash。</li><li>TDengine 的文件只会在非 posix 环境被用到。</li><li>去掉 TDengine 的静态库仍然会 crash。</li></ol><h3 id="将-Config-移出-coroutine"><a href="#将-Config-移出-coroutine" class="headerlink" title="将 Config 移出 coroutine"></a>将 Config 移出 coroutine</h3><p>无论如何,在 coroutine 中初始化带有 <code>std::mutex</code> 的对象还是有点奇怪的(至少部分观点这么认为),那我们将它移出去构造好,再将引用传给 coroutine,看看会发生什么。</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">folly::<span class="function">coro::Task<<span class="type">void</span>> <span class="title">run</span><span class="params">(Config &config)</span> </span>{</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> IOWorker worker;</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> Runners runners;</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line"> <span class="keyword">co_await</span> runners.<span class="built_in">run</span>();</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>{</span><br><span class="line"> Config config;</span><br><span class="line"> folly::coro::<span class="built_in">blockingWait</span>(<span class="built_in">run</span>(config));</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>果然,<code>Config</code> 不 crash 了,改在构造 <code>IOWorker</code> 时 crash 了……</p><p>crash 的位置还是 <code>__GTHREAD_MUTEX_INIT</code>。</p><h3 id="valgrind"><a href="#valgrind" class="headerlink" title="valgrind"></a>valgrind</h3><p>从最前面 crash 的 stack 来看,<code>this</code> 的值明显不对,会不会是内存写坏了?在老司机建议下,我们用 valgrind 跑了一下,一无所获。</p><h3 id="builtin-return-address"><a href="#builtin-return-address" class="headerlink" title="__builtin_return_address"></a>__builtin_return_address</h3><p>重大突破(虽然事后证实是假象):换用 gcc 之后 crash 消失了!</p><p>我们在 crash 的 stacktrace 中找到了 coroutine::resume,看起来 <code>folly::coro::blockingWait</code> 一定会先 suspend 再 resume。会不会是 clang 的 resume 有 bug,它跳到了错误的地址?</p><p>我们在 folly 代码中看到了 <code>__builtin_return_address</code>,未经证实,就觉得它是凶手。正好又搜到了这个答案:</p><p><a href="https://stackoverflow.com/questions/65638872/why-does-builtin-return-address-crash-in-clang">Why does __builtin_return_address crash in Clang?</a></p><p>它里面说 clang 可能需要强制设置 <code>-fno-omit-frame-pointer</code> 来确保正确回溯 frame。我们的项目恰好没有显式设置这个 flag,加上试试。</p><p>还是不行:</p><ol><li><code>__builtin_return_addres(0)</code> 不需要设置这个 flag 就可以正确工作。</li><li>我们的进程并没有 crash 在 return 时,而是在 coroutine 运行时,本来就不该关注这里。</li></ol><h3 id="事情开始变得奇怪起来"><a href="#事情开始变得奇怪起来" class="headerlink" title="事情开始变得奇怪起来"></a>事情开始变得奇怪起来</h3><p>陷入困境,尤其是我们甚至不知道该给谁开 bug(folly 还是 clang?)。</p><p>鉴于现场还比较复杂,我们开始着手简化现场,搞个最小化 case 出来。</p><p>于是事情开始变得奇怪起来:注释掉 <code>run</code> 中的唯一的 <code>co_await</code> 之后,crash 消失了!但 <code>co_await</code> 明明是发生在 crash 的位置之后,也就是说注释掉后面代码会影响前面代码的行为。</p><p>去掉了 <code>co_await</code> 之后 <code>run</code> 内部就不再有 suspend point 了,因此 clang 不会在内部为其产生 async stack frame(用于 resume)。这是一个非常关键的线索。</p><p>顺着这个线索,我们发现即使 <code>co_await</code> 一个 dummy function,也会引入 crash。</p><p>接下来,我们开始二分注释代码,立求将 <code>run</code> 简化到最小。</p><h3 id="最小化-case-v1"><a href="#最小化-case-v1" class="headerlink" title="最小化 case v1"></a>最小化 case v1</h3><p>……最终,我们得到了这么一个 case:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">folly::<span class="function">coro::Task<<span class="type">void</span>> <span class="title">dummy</span><span class="params">()</span> </span>{ <span class="keyword">co_return</span>; }</span><br><span class="line"></span><br><span class="line">folly::<span class="function">coro::Task<<span class="type">void</span>> <span class="title">run</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="function">folly::CPUThreadPoolExecutor <span class="title">executor</span><span class="params">(<span class="number">1</span>)</span></span>;</span><br><span class="line"> <span class="function"><span class="keyword">co_await</span> <span class="title">dummy</span><span class="params">()</span></span>;</span><br><span class="line"> executor.<span class="built_in">add</span>([] {}); <span class="comment">// prevent `executor` from eliminated by compiler</span></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="built_in">TEST</span>(Test, Normal) {</span><br><span class="line"> folly::coro::<span class="built_in">blockingWait</span>(<span class="built_in">run</span>());</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>看起来已经非常明显了,一定是 folly 或者 clang 中的一个的 bug。只是我们还不知道该给谁发 bug。</p><h3 id="事情变得更加奇怪了"><a href="#事情变得更加奇怪了" class="headerlink" title="事情变得更加奇怪了"></a>事情变得更加奇怪了</h3><p>……还没完。</p><p>我们发现,上面这个 gtest 行为非常奇怪:</p><ol><li><code>run --gtest_filter="Test.Normal"</code> 会 crash。</li><li><code>run --gtest_filter="Test.*"</code> 不会 crash。</li><li><code>run --gtest_filter="Test.Normal*"</code> 不会 crash。</li></ol><h2 id="转"><a href="#转" class="headerlink" title="转"></a>转</h2><p>一个周末过去了,我们觉得还是应该把这个 bug 查清楚(关系到我们还能不能继续使用 coroutine),至少这不是随机 crash 吧。</p><p>我们将 <code>CPUThreadPoolExecutor</code> 变成在堆上分配(<code>std::unique_ptr</code>)之后,crash 就消失了,进一步说明 crash 和 coroutine async frame 有关,一定是有某个东西在 coroutine 栈上分配就会导致 crash。</p><p>接下来,我们将 <code>CPUThreadPoolExecutor</code> 的所有成员显式分配到栈上,二分排除,最终找到了最小化 case v2。</p><h3 id="最小化-case-v2"><a href="#最小化-case-v2" class="headerlink" title="最小化 case v2"></a>最小化 case v2</h3><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">folly::<span class="function">coro::Task<<span class="type">void</span>> <span class="title">run</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="type">char</span> padding0[<span class="number">88</span>];</span><br><span class="line"> folly::LifoSem sem;</span><br><span class="line"> std::deque<<span class="type">int</span>> queue;</span><br><span class="line"> <span class="type">char</span> padding1[<span class="number">72</span>];</span><br><span class="line"></span><br><span class="line"> <span class="function"><span class="keyword">co_await</span> <span class="title">dummy</span><span class="params">()</span></span>;</span><br><span class="line"> <span class="comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>最小化过程中我们为被排除掉的变量都申请了同样大小的栈内存。</p><p>这已经和 <code>std::mutex</code> 没关系了,我们一开始的方向完全是错的!</p><h3 id="定位到-alignment"><a href="#定位到-alignment" class="headerlink" title="定位到 alignment"></a>定位到 alignment</h3><p>仔细查看 <code>folly::LifoSem</code> 的实现,我们发现它是 cacheline 对齐的,但 stacktrace 显示它的地址不能被 64 整除。</p><p>Wow,amazing,unbelivable。</p><h2 id="合"><a href="#合" class="headerlink" title="合"></a>合</h2><p>隐约记得之前在怀疑 clang 的时候看过它的 open issues,里面有个似乎和 alignment 有关:</p><p><a href="https://github.com/llvm/llvm-project/issues/56671">Clang misaligns variables stored in coroutine frames</a></p><p>它大概说的是:</p><ol><li>一个 coroutine function 里,如果 <code>co_await</code> 前面有变量需要 <code>alignment > 8</code>,clang 不保证分配出来的 async stack frame 满足这个条件。</li><li>这个 bug 不是 clang 自己的问题,它是严格按 std 标准实现的,是标准没有包含这项要求。</li><li>2020 年已经有提案说这件事了(<a href="https://wg21.link/p2014r0">wg21.link/p2014r0</a>),但被人关了,今年又 reopen,看看能不能进 C++26(f**k)。</li><li>如果 clang 自己做了扩展,需要应用自己的 <code>promise_type</code> 不会重载 <code>operator new</code>,否则 clang 也没办法介入。</li></ol><p>和我们遇到的情况,不能说一模一样吧,至少也是同一个 bug。</p><p>这样,前面种种奇怪现象也都有了合理解释:编译期的 bug 导致了运行期异常,具体到 <code>operator new</code> 返回的地址是否对齐。</p><p>终于水落石出了。但 coroutine 能不能安心继续用呢?我们知道 alignment 是非常常用的优化手段,尤其是 cacheline 对齐,coroutine 里也经常会这么定义一个变量。但这个 bug 的存在(尤其是它至少要存活到 2026 年),我们随时可能撞上诡异的 crash。</p><p>幸好我不负责这个项目,不用我去头疼。</p>]]></content>
<summary type="html"><p><strong>TL;DR</strong></p>
<p>C++20 coroutine 有一个严重的 <a href="https://github.com/llvm/llvm-project/issues/56671">bug</a>,且这个 bug 本质上来源于 C++ 标准不完善:在分配 coroutine frame 时,没有严格按 alignment 要求。目前看起来 gcc 与 clang 都中招了,只有 msvc 似乎没问题。</p>
<p>本文记录了我是如何被这个 bug 消耗掉了<del>两</del>三天光明。</p></summary>
</entry>
<entry>
<title>一种通过 skip cache 加速重复查询的方法</title>
<link href="http://fuzhe1989.github.io/2022/08/03/maintain-runtime-skip-cache-for-dynamic-filtering/"/>
<id>http://fuzhe1989.github.io/2022/08/03/maintain-runtime-skip-cache-for-dynamic-filtering/</id>
<published>2022-08-03T13:33:18.000Z</published>
<updated>2022-08-03T13:33:41.315Z</updated>
<content type="html"><![CDATA[<p><strong>TL;DR</strong></p><p>AP 系统中缓存算子结果是一种很有效的针对重复查询的优化手段。但这种方法严重依赖于结果的不变性,因此并不适用于频繁更新的场景(如 TiFlash)。本文提出一种通过维护运行期的 skip cache,尽可能跳过无效 page 的优化方法,<strong>应该</strong>对这种场景有效。</p><blockquote><p>有 paper 已经讲过这种优化的话求告知。</p></blockquote><span id="more"></span><h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>任何缓存结果的方法,其核心都是要寻找到某种不变的东西。传统的离线数仓系统,其数据更新频率极低,相同 query plan 通常扫过的数据集是不变的。如果 plan 中不存在非幂等的函数/算子(通常是这样),则每个算子的结果也是不变的。这是缓存算子结果的基础。</p><p>但在 TiFlash 中,我们需要考虑到用户可能频繁在更新数据,同时 query plan 也很难保证稳定。因此直接缓存算子结果可能并不合适。</p><blockquote><p>虽然但是,有机会还是得搞,见文末。</p></blockquote><p>materialize 这样的实时物化是另一种方案,但计算开销较大。而且人家属于另一个赛道了。我们还是从简单的优化入手。</p><h2 id="从-skip-index-到-skip-cache"><a href="#从-skip-index-到-skip-cache" class="headerlink" title="从 skip index 到 skip cache"></a>从 skip index 到 skip cache</h2><p>Skip index 是 AP 系统非常关键的模块,它的作用是记录每个 page(或者叫 block)的一些 metadata(如 min/max 等),在 TableScan 时提前过滤那些不可能包含所需数据的 page,从而节省大量 IO 与计算资源。</p><blockquote><p>为什么 AP 系统通常不使用 secondary index 来加速查询?</p><ol><li>维护 secondary index 的代价是非常高的。</li><li>查询 secondary index 时通常会引入大量随机 I/O(无论是读 index 还是回表时)。而 AP 系统可以通过 skip index 跳过大量无效的 page,而对剩余部分进行顺序扫描的效率是非常高的(I/O 和 cache 友好)。</li></ol></blockquote><p>skip index 的缺点是它通常只能包含静态数据,一旦遇到复杂一点的表达式就无能为力了(比如 <code>year(a) = 2022</code> 或者 <code>concat(a, b) like '%PingCAP%'</code>)。对于这种复合表达式的 filter,很多系统(如现阶段的 TiFlash)只能无脑扫全表了。</p><p>一种很直接的想法就是,如果我能知道一个表达式是否命中了一个 page,就可以在下次遇到这个表达式时提前排除掉对应的 page,重新让 skip index 生效。显然这种信息是非常动态的,不适合持久化,只能放到内存中。</p><p>方案一:在内存中缓存每个表达式扫描未命中的 page list。</p><p>注意,这个方案中,page 指的是 stable file 中的 page。如前所述,我们要缓存结果,就要找到某种不变的东西。TiFlash 是按 delta 和 stable 来划分数据的,前者变化频繁,后者变化较少,显然我们只能针对 stable 来缓存。恰好,TiFlash 的 skip index 也是只存在于 stable 部分的。</p><p>stable 只是变化不那么频繁,不代表它永远不变。方案一要保证不能跳过新生成的 page,就要记录未命中的 page list,将所有新 page 视为可能命中。这对方案的实现提出了要求:</p><ol><li>能识别哪些 page 是在缓存更新之后生成的。通常记录某种单调增的 version 即可。</li><li>每个 page 要有稳定的唯一标识,即新 page 不能重用老 page 的标识。这个可以用 fileid+pageid 来实现。</li></ol><p>skip cache 可以用 LRU 或 LFU 等策略管理。</p><h2 id="降低内存占用"><a href="#降低内存占用" class="headerlink" title="降低内存占用"></a>降低内存占用</h2><p>方案一的缺点是当 page 很多时,内存占用较高。我们需要有办法将 page list 占用的空间降下来。我们可以将 page list 替换为 bloomfilter,后者通过引入适量的 false positive 来降低空间占用。</p><blockquote><p>另一种类似的结构是 cuckoo filter。</p></blockquote><p>但换用 bloomfilter 之后,我们就要将方案一中记录未命中的 page list 改为记录命中的 page list 了,否则 false positive 会导致有 page 被错误地跳过。</p><p>方案二:在内存中缓存每个表达式扫描命中的 page list 对应的 bloomfilter。</p><p>同样地,方案二也要求显式处理新生成的 page。</p><h2 id="在分布式-plan-中寻找不变量"><a href="#在分布式-plan-中寻找不变量" class="headerlink" title="在分布式 plan 中寻找不变量"></a>在分布式 plan 中寻找不变量</h2><p>上面的方案只是针对单个 TableScan 算子。接下来我想做点不成熟的探讨。</p><p>我们知道分布式 plan 对各种结果 cache 都不太友好:</p><ol><li>数据可能更新,而 planner 很难及时知道这点。</li><li>数据分布可能变化,同上。</li><li>参与计算的节点可能变化。</li></ol><p>但总还是有些不变量存在的,我们要做的就是充分地将其挖掘出来。</p><p>对于 TiFlash 而言:</p><ol><li>首先 planner 要能知道 query 是否存在缓存。如果做得好的话,可以针对 subplan 设置缓存。</li><li>接下来,planner 是知道这次需要扫描的 region list 的,它需要知道从上次请求到当前 tso,这期间哪些 region 数据没有发生变化。</li><li>接下来,针对这些没有变化过的 region,自底向上计算每个算子的输入是否可能发生变化。</li><li>接下来,计算每个 task 命中缓存的收益,从而决定要不要生成相同的 task 且分发给相同的 node。</li></ol><p>即使如此,如果算子产生的数据过多的话,需要将其物化才能重复利用,开销一下子就上去了。</p><p>从上述内容可以看出,想用上算子的缓存还是不太容易的。</p>]]></content>
<summary type="html"><p><strong>TL;DR</strong></p>
<p>AP 系统中缓存算子结果是一种很有效的针对重复查询的优化手段。但这种方法严重依赖于结果的不变性,因此并不适用于频繁更新的场景(如 TiFlash)。本文提出一种通过维护运行期的 skip cache,尽可能跳过无效 page 的优化方法,<strong>应该</strong>对这种场景有效。</p>
<blockquote>
<p>有 paper 已经讲过这种优化的话求告知。</p>
</blockquote></summary>
</entry>
<entry>
<title>C++ 变参宏的两个技巧</title>
<link href="http://fuzhe1989.github.io/2022/08/01/cpp-variadic-macro-tips/"/>
<id>http://fuzhe1989.github.io/2022/08/01/cpp-variadic-macro-tips/</id>
<published>2022-08-01T13:22:09.000Z</published>
<updated>2022-08-03T11:39:58.051Z</updated>
<content type="html"><![CDATA[<p><strong>TL;DR</strong></p><blockquote><p>小朋友不要乱学</p></blockquote><ol><li>基于参数数量重载宏函数。</li><li>当 <code>__VA_ARGS__</code> 为空时,忽略多余的逗号。</li></ol><span id="more"></span><h2 id="基于参数数量重载宏函数"><a href="#基于参数数量重载宏函数" class="headerlink" title="基于参数数量重载宏函数"></a>基于参数数量重载宏函数</h2><blockquote><p>参考这个回答:<a href="https://stackoverflow.com/questions/11761703/overloading-macro-on-number-of-arguments">Overloading Macro on Number of Arguments</a></p></blockquote><p>大概套路就是:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="type">void</span> <span class="title">f0</span><span class="params">()</span></span>;</span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f1</span><span class="params">(<span class="type">int</span> a)</span></span>;</span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f2</span><span class="params">(<span class="type">int</span> a, <span class="type">int</span> b)</span></span>;</span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">f3</span><span class="params">(<span class="type">int</span> a, <span class="type">int</span> b, <span class="type">int</span> c)</span></span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// define K concrete macro functions</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> FUNC_0() f0()</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> FUNC_1(a) f1(a)</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> FUNC_2(a, b) f2((a), (b))</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> FUNC_3(a, b, c) f3((a), (b), (c))</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// define a chooser on arguments count</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> FUNC_CHOOSER(...) GET_4TH_ARG(__VA_ARGS__, FUNC_3, FUNC_2, FUNC_1, FUNC_0)</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// define a helper macro</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> GET_4TH_ARG(a1, a2, a3, a4, ...) a4</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// define the entry macro</span></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> FUNC(...) FUNC_CHOOSER(__VA_ARGS__)(__VA_ARGS__)</span></span><br></pre></td></tr></table></figure><p>上面这个例子中,我们希望通过一个统一的入口(<code>FUNC</code>),根据参数数量重载几个具体的宏(<code>FUNC_0</code> 到 <code>FUNC_3</code>)。</p><p>具体做法是将宏定义分成两部分,首先通过参数数量来选择具体的宏名字,再将参数传入这个具体的宏,完成调用。</p><p>第一部分入口是:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">FUNC_CHOOSER</span>(__VA_ARGS__)</span><br></pre></td></tr></table></figure><p>我们看到它会被展开成</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">GET_4TH_ARG</span>(__VA_ARGS__, FUNC_3, FUNC_2, FUNC_1, FUNC_0)</span><br></pre></td></tr></table></figure><p>假如我们传入的参数为 <code>FUNC(0, 1)</code>,则 <code>__VA_ARGS__</code> 展开成 <code>0, 1</code>,上面的表达式展开成</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET_4TH_ARG(0, 1, FUNC_3, FUNC_2, FUNC_1, FUNC_0)</span><br></pre></td></tr></table></figure><p><code>GET_4TH_ARG</code> 的结果是只保留第 4 个参数,恰好就是我们要的 <code>FUNC_2</code>。再之后的过程就很直接了。</p><p>这种方法的关键就是 <code>FUNC_CHOOSER</code> 中目标宏的顺序要逆序,从而实现根据参数数量选择正确的目标宏。</p><p>上面的方案有几点要注意:</p><ol><li>参数数量要连续。如果只存在 <code>FUNC_3</code> 和 <code>FUNC_0</code>,我们需要填充几个 dummy name 人为制造报错。</li><li>数量数量必须是确定的。对于不定数量的调用,只能硬着头皮从 1 定义到某个超大的数(如 67)。(比如<a href="https://github.com/pingcap/tiflash/pull/5512">这个例子</a>)</li></ol><h2 id="忽略多余的逗号"><a href="#忽略多余的逗号" class="headerlink" title="忽略多余的逗号"></a>忽略多余的逗号</h2><blockquote><p>参考这个回答:<a href="https://stackoverflow.com/questions/39291976/c-preprocessor-remove-trailing-comma">C Preprocessor Remove Trailing Comma</a></p></blockquote><p><a href="https://godbolt.org/z/jdfbzxac6">这个例子</a> 中我们用到了一个可变参数的宏来调用一个可变参数的函数:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#<span class="keyword">include</span> <span class="string"><fmt/format.h></span></span></span><br><span class="line"></span><br><span class="line"><span class="keyword">template</span> <<span class="keyword">typename</span> S, <span class="keyword">typename</span>... Args></span><br><span class="line"><span class="function"><span class="type">void</span> <span class="title">print</span><span class="params">(<span class="type">const</span> S & fmt_str, Args &&... args)</span> </span>{</span><br><span class="line"> fmt::<span class="built_in">print</span>(fmt_str, std::forward<Args>(args)...);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="meta">#<span class="keyword">define</span> PRINT(fmt_str, ...) print(fmt_str, __VA_ARGS__)</span></span><br><span class="line"></span><br><span class="line"><span class="function"><span class="type">int</span> <span class="title">main</span><span class="params">()</span> </span>{</span><br><span class="line"> <span class="built_in">PRINT</span>(<span class="string">"a = {}, b = {}"</span>, <span class="number">1</span>, <span class="number">2</span>);</span><br><span class="line"> <span class="built_in">PRINT</span>(<span class="string">"xxx"</span>);</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>看起来一切都 OK,直到编译时:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"># gcc</span><br><span class="line"><source>: In function 'int main()':</span><br><span class="line"><source>:8:55: error: expected primary-expression before ')' token</span><br><span class="line"> 8 | #define PRINT(fmt_str, ...) print(fmt_str, __VA_ARGS__)</span><br><span class="line"> | ^</span><br><span class="line"><source>:12:5: note: in expansion of macro 'PRINT'</span><br><span class="line"> 12 | PRINT("xxx");</span><br><span class="line"> | ^~~~~</span><br><span class="line"></span><br><span class="line"># clang</span><br><span class="line"><source>:12:5: error: expected expression</span><br><span class="line"> PRINT("xxx");</span><br><span class="line"> ^</span><br><span class="line"><source>:8:55: note: expanded from macro 'PRINT'</span><br><span class="line">#define PRINT(fmt_str, ...) print(fmt_str, __VA_ARGS__)</span><br><span class="line"> ^</span><br></pre></td></tr></table></figure><p>原因是 <code>PRINT("xxx")</code> 会导致 <code>PRINT</code> 中的 <code>__VA_ARGS__</code> 为空,展开时产生了一个多余的逗号:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">print</span>(fmt_str, )</span><br></pre></td></tr></table></figure><p>这个问题看起来有两种解法:</p><ol><li>用 <code>__VA_OPT__(,)</code> 处理逗号。亲测可用,但只能用于 <a href="https://gcc.gnu.org/onlinedocs/cpp/Variadic-Macros.html">gcc</a>。</li><li>用 <code>##__VA_ARGS__</code>,它可以在展开为空时消除掉前面的逗号。亲测 gcc 与 clang 都可用。</li></ol>]]></content>
<summary type="html"><p><strong>TL;DR</strong></p>
<blockquote>
<p>小朋友不要乱学</p>
</blockquote>
<ol>
<li>基于参数数量重载宏函数。</li>
<li>当 <code>__VA_ARGS__</code> 为空时,忽略多余的逗号。</li>
</ol></summary>
<category term="C++" scheme="http://fuzhe1989.github.io/tags/C/"/>
</entry>
<entry>
<title>数据丢失概率与节点数量的关系</title>
<link href="http://fuzhe1989.github.io/2022/07/01/probability-of-data-loss-when-nodes-increase/"/>
<id>http://fuzhe1989.github.io/2022/07/01/probability-of-data-loss-when-nodes-increase/</id>
<published>2022-07-01T04:23:54.000Z</published>
<updated>2022-07-26T00:01:50.659Z</updated>
<content type="html"><![CDATA[<p><strong>TL;DR</strong></p><p>分布式系统中我们经常会使用多副本策略来保证数据的可靠性。常见的多副本策略可以按容错能力分为两类。假设系统需要能容忍最多 f 个节点失败:</p><ol><li>需要 2f+1 个副本的 Quorum 策略,如 Paxos/Raft </li><li>需要 f+1 个副本,如 chain replication(下文称 CR)。</li></ol><p>本文通过简单的模拟计算,得到以下结论:</p><ol><li>固定节点失败概率与每个节点上的 shard 数量,数据丢失概率是节点数量的凸函数,即随着节点数量增加,数据丢失概率逐渐增大,到达峰值后再逐渐减小。</li><li>同等存储成本下,CR 的数据丢失概率远低于 Quorum。</li></ol><span id="more"></span><p>我们假设节点失败概率为 P,每个节点上有 K 个 shard,每个 shard 有 3 个副本。对于 Quorum,shard 容忍最多一个副本失败。对于 CR,shard 容忍最多两个副本失败。</p><p>数据丢失可以被定义为:当有超过 f 个节点同时失败,且存在 shard 恰好有超过 f 个副本位于这些节点上。这样我们可以将数据丢失概率计算为以下两个概率的乘积:</p><ol><li>超过 f 个节点同时失败的概率。</li><li>存在 shard 恰好有超过 f 个副本位于这些节点的概率。</li></ol><p>前者我们记为 P<sub>n</sub>,后者记为 P<sub>s</sub>。为了简化计算,下面我们只计算 shard 恰好有 f+1 个副本位于这些节点的概率。且记 P<sub>ss</sub> 为一个 shard 发生数据丢失的概率。则 P<sub>s</sub> = (1-P<sub>ss</sub>)<sup>NK</sup>。</p><p>对于Quorum,f = 1,则发生了超过 2 个节点失败,且有 shard 有 2 个副本位于其上的概率为:</p><ul><li>P<sub>n</sub> = P<sub>n</sub> = 1 - (1-P)<sup>n</sup> - C(N, 1) * P(1-P)<sup>N-1</sup></li><li>P<sub>ss</sub> = C(2, 2) * C(N-2, 1) / C(N, 3)</li><li>P<sub>s</sub> = (1-P<sub>ss</sub>)<sup>NK</sup></li><li>P<sub>res</sub> = P<sub>n</sub> * P<sub>s</sub></li></ul><p>对于 CR,f = 2,则发生了超过 3 个节点失败,且有 shard 有 3 个副本位于其上的概率为:</p><ul><li>P<sub>n</sub> = 1 - (1-P)<sup>n</sup> - C(N, 1) * P(1-P)<sup>N-1</sup> - C(N, 2) * P<sup>2</sup>(1-P)<sup>(N-2)</sup>,其中分别减掉了:<ul><li>所有节点都正常的概率</li><li>只有一个节点失败的概率</li><li>只有两个节点失败的概率</li></ul></li><li>P<sub>ss</sub> = C(3, 3) / C(N, 3)</li><li>P<sub>s</sub> = (1-P<sub>ss</sub>)<sup>NK</sup></li><li>P<sub>res</sub> = P<sub>n</sub> * P<sub>s</sub></li></ul><blockquote><p>以上对于 P<sub>ss</sub> 的计算做了一些简化,但不影响结论。</p></blockquote><p>可以看到 P<sub>n</sub> 是关于 n 的单调增函数,而 P<sub>s</sub> 则是关于 n 的单调减函数。</p><p>接下来直接上图。</p><p>Quorum</p><p>P = 0.001,K = 1000</p><p><img src="/images/2022-07/data-loss-prob-01.png"></p><p>P = 0.001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-02.png"></p><p>P = 0.0001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-03.png"></p><p>P = 0.00001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-04.png"></p><p>Chain replication(注意 Y 轴)</p><p>P = 0.001,K = 1000</p><p><img src="/images/2022-07/data-loss-prob-05.png"></p><p>P = 0.001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-06.png"></p><p>P = 0.0001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-07.png"></p><p>P = 0.00001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-08.png"></p><p>可以看到:</p><ol><li>随着节点数量增加,数据丢失概率先增大后减小。</li><li>同样 3 副本,CR 因为可以容忍 2 副本失败,相同参数下数据丢失概率远小于 Quorum。</li></ol><p>如果我们将 CR 设置为 2 副本,因此同样容忍 1 副本失败(但更省存储空间),此时 P<sub>n</sub> 与 Quorum 相同,而 P<sub>ss</sub> = C(2, 2) / C(N, 2),小于 Quorum。</p><p>P = 0.001,K = 1000</p><p><img src="/images/2022-07/data-loss-prob-09.png"></p><p>P = 0.00001,K = 5000</p><p><img src="/images/2022-07/data-loss-prob-10.png"></p><p>可以看到,CR 用更少的存储空间实现了更低的数据丢失概率。</p><p>启示:</p><ol><li>只考虑存储空间与数据可靠性的话,Chain replication 相比 Quorum(Paxos/Raft)更适合用于数据平面。</li><li>在集群节点数量增加时,需要考虑是否有必要增加副本数量。</li></ol><p>上述结论与数据分布无关,如 Copyset 等策略相当于降低了 P<sub>ss</sub>,正交于具体的共识算法。</p><blockquote><p>安利一个网站:<a href="https://www.desmos.com/calculator">https://www.desmos.com/calculator</a></p></blockquote>]]></content>
<summary type="html"><p><strong>TL;DR</strong></p>
<p>分布式系统中我们经常会使用多副本策略来保证数据的可靠性。常见的多副本策略可以按容错能力分为两类。假设系统需要能容忍最多 f 个节点失败:</p>
<ol>
<li>需要 2f+1 个副本的 Quorum 策略,如 Paxos&#x2F;Raft </li>
<li>需要 f+1 个副本,如 chain replication(下文称 CR)。</li>
</ol>
<p>本文通过简单的模拟计算,得到以下结论:</p>
<ol>
<li>固定节点失败概率与每个节点上的 shard 数量,数据丢失概率是节点数量的凸函数,即随着节点数量增加,数据丢失概率逐渐增大,到达峰值后再逐渐减小。</li>
<li>同等存储成本下,CR 的数据丢失概率远低于 Quorum。</li>
</ol></summary>
</entry>
</feed>