kevy
本页内容

packed-rows——把声明过的行存成一次分配

TABLE.DECLARE 已经把一张表的完整形状告诉了服务器。打开 packed-rows 之后,该表前缀下的行不再为每行付一张哈希表,而是把各列的值按声明顺序存在一小段偏移表后面。

默认关闭,理由在下面「顺序」那一节。

什么时候需要它

行有三到十二列、而且内存要紧的表。这正是通用哈希浪费最大的区间:一个从内联形式提升上来的哈希,会向 kevy-map 要它能给的最小一张表——十六个槽——不管这行是三列还是十二列。

它的签名是:一行的固定开销与它有多少列无关。实测五万行,每行 RSS:三列 1699 字节,七列 1694,十二列 1713——而它们的载荷相差 91 字节。

核心思路

一行声明过的行变成一次分配:

[ncol u16][存在位图][end_1 u16] … [end_n u16][各列字节 …]

列的顺序来自声明,所以不存字段名——它们本来就在目录里。缺失的列只花一个 bit。这一切在协议层完全不可见:HGETHSETHDELHGETALLHRANDFIELDHSCAN 以及字段 TTL 系列动词的回答和以前一模一样。

两种情况会让某一行换回通用形式,不报错也不丢值:写入一个表没有声明的列,以及这行的值超出 u16 偏移能寻址的范围。打包行是一个尺寸类别加一份声明,不是一个类型。

怎么打开

[server]
packed_rows = true

KEVY_PACKED_ROWS=1,或在运行时:

CONFIG SET packed-rows yes

CONFIG SET 改的是运行中的配置,不是文件——重启读的是文件,所以一台在运行时被要求打包的服务器,重启后是不打包的。

声明一张表时已经存在的行,由回填在 shard tick 上分批转换,所以对着活的键空间发声明不会把它卡住。

它省下什么,代价是什么

发布机上实测,两百万行、七列、其中一列 400 字节,三轮交错,同一个二进制只差一个开关:

AOF 模式
5,5044,760−13.5%
everysec5,8245,527−5.1%
always5,8825,137−12.7%
开了分层5,2215,460+4.6%

单位是每 MB 源 CSV 占用的常驻内存 KB。装载吞吐不变(相差不到千分之一)。点查和索引查找不变。

列表翻页贵约 7%(p50 145 → 155 µs)。打包行靠扫描自己声明的列名来找一列——一把短切片——这正是「不要每行一张哈希表」的代价,而翻页这个形状要在二十行上各取一列。

顺序,它比上面任何一项都重要

省不省得下来,取决于表是什么时候声明的

顺序
先声明,后装载1,6631,274−23.4%
先装载,后声明1,6631,721+3.5%

单位是每行常驻字节,五十万行,无索引。先声明意味着每一行从建起来就是打包的,通用形式根本没被分配过。后声明意味着每一行先以通用形式建起来,回填再在它要替换的那张表旁边分配一段打包缓冲、然后释放那张表——而回到进程手里的,只有分配器愿意还的那部分

两种顺序的峰值一模一样,这正是要点:不同的不是表示法,是历史。

本页的两组测量彼此不一致,而且为它给出的那个解释已经被实测推翻了。 上面那张 AOF 模式表同样是「先装载后声明」,它 13.5%;而这张表里同一个顺序 3.5%。最显然的嫌疑是索引:基准那张表声明了索引,而索引自己的回填正好在打包回填刚刚释放完内存的地方大量分配,如果分配器把那些表拿去复用而不是攥着,差异就说得通了。

实测,同一个探针加上索引声明,三轮取中位数:

先装载后声明
无索引1,6631,721+3.4%
带索引1,8282,186+19.6%

索引让它更差;规模也不是(两百万行同样是 +3.5%);分片数与 AOF 设置两边一致;把批量装载换成逐条往返也毫无差别(+3.6% 对 +3.5%)。

答案是查询阶段。 基准在装载之后跑四种查询形状各 5000 次,而此前没有任何探针跑过哪怕一次。补上之后,三轮取中位数:

先装载后声明,带索引
不跑查询1,8482,077+12.4%
跑查询2,2072,133−3.4%

符号翻转了,而那个不对称说明了原因:查询阶段给通用形式加了每行 359 字节,给打包形式只加了 56 —— 后者三轮都落在 2,133,一个字节不差,而前者散在 244 字节的区间里。

收益是在读的时候拿到的,不是在写的时候。 往打包行里写要付代价:在被替换的那张表旁边分配一段缓冲,再加上分配器不肯归还的部分。而从通用哈希里一个字段要走一次表、交出一个 SmallBytes;打包行的值是连续的一段,读它几乎不分配。一万次读之后,这就是每行 300 字节的 arena 残留。

一行写一次、读很多次 —— 这正是这个形式存在的场景,也正是只测装载的测量永远看不到的那一半。

把 −23.4% 读作表示法自己的效果。而每一个「先装载」的数字都在说:回填这条路的结果取决于本页尚未识别出的某个因素,并且在五十万行的规模上,两种测过的配置里它都是花内存。

所以:

  • 只要顺序在你手里,就先声明表再装载。 −23% 在那里。
  • 在已有的键空间上采用它是另一行,在分配器把回填释放的内存还回来之前,它可能是花内存而不是省内存。还多少是平台的性质,不是 kevy 的:同一个实验在 macOS 上稳定在 +55%,而不是 +3.5%。

取舍

  • 分层。 降级预算是以存储引擎自己的记账计的,而打包会把那个数字降下来。于是开了分层的实例会更早认为自己在预算之内,把更多行留在内存里——这是预算在照吩咐办事,也是上表里分层那一行走反方向的原因。它的写反而快很多,是同一件事的另一面:留在热区的行,写的时候不走分层路径。
  • 未声明的键保持它们今天的一切表示法。 打包形式是靠声明挣来的,从不强加。
  • 极小的哈希仍然是内联形式更划算,它排在这一形式前面。

FAQ

AOF 或快照的格式变了吗? 没有。AOF 是一份命令日志,所以打包行是靠重放 5.3 日志里本来就有的那些 HSET 帧建起来的,而 5.3 的二进制读 5.4 的日志也毫无变化。快照重新写出的仍是同一种载荷形状。

副本必须和主库一致吗? 实践上是:故障切换到一台用另一种方式存行的副本,会改变整个部署的内存画像。这正是这个开关可以热改的原因。

我怎么知道某一行是不是打包的? 对它跑 MEMORY USAGE,前后各一次。这里刻意没有任何动词去报告存储形式:一个据此分支的客户端,会依赖上它绝不该依赖的东西。