分片
DocStore 为每条记录写入一个文件。如果不进行分片,大型集合的所有文件都会堆积在同一个目录中,随着条目数量增加,目录操作会变慢。因此,storh 按记录 id 的两个字符对记录文件进行分桶:
pages/data/2d/018bcfe5-6800-7000-8000-2d5dba6274da.jsonc
分桶依据取自 UUID 最后一组的开头(第 24 和第 25 个字符),这些字符是随机熵。两个十六进制字符可形成 256 个桶,因此包含 100k 条记录的集合平均每个目录不到 400 个文件。
为什么取末尾而不是开头
UUIDv7 id 以 48 位毫秒时间戳开头,因此相近时间创建的 id 具有相同的前导字符。按前缀分片会让每一阵写入都集中到同一个繁忙目录中,只有随着时间推移才会切换桶。每条记录的末尾字节都是随机的,因此从第一条记录开始,写入就会均匀分散。
分片对读取和查询不可见:get() 根据 id 计算桶,而 stream() 会将所有桶中的记录按 id 排序后返回,因此游标分页的效果就像集合是一份有序列表。
大规模场景
桶的数量不会增加:无论集合有多大,两个十六进制字符始终对应 256 个目录。增长的是每个桶中的文件数:
| 记录数 | 每个桶中的文件数 |
|---|---|
| 10k | ~40 |
| 100k | ~390 |
| 1M | ~3,900 |
| 10M | ~39,000 |
现代文件系统会为目录条目建立索引,因此每个桶中只有几千个文件时,点查找依然很快;对于 get() 和索引查询而言,1M 条记录也能轻松应对。无论集合大小如何,打开存储所需的时间都是固定的:只有在发现失效的写入者标记表明先前进程在写入中途崩溃时,才会清理孤立的临时文件。随着规模扩大而线性增长的是整集合操作:未建立索引的扫描、reindex() 和 verify() 都会遍历每个记录文件,因此应将它们作为批处理任务。每个集合超过约 10M 条记录后,每桶记录数和 inode 总用量就开始变得重要:拆分集合,或者对以扫描为主的数据使用分段日志,其设计本身就能保持较低的文件数。
其他引擎
SegmentedLog 和 Queue 不会进行分片,因为它们不会为每条记录创建一个文件。日志会将内容追加到 segments/ 下的分段文件中,并受 max_segment_bytes 限制(默认 1 MiB);队列则将事件追加到单个 queue.log 中。其设计本身就能保持较低的文件数。