packed-rows——把声明过的行存成一次分配
TABLE.DECLARE 已经把一张表的完整形状告诉了服务器。打开 packed-rows 之后,该表前缀下的行不再为每行付一张哈希表,而是把各列的值按声明顺序存在一小段偏移表后面。
它默认关闭,理由在下面「顺序」那一节。
什么时候需要它
行有三到十二列、而且内存要紧的表。这正是通用哈希浪费最大的区间:一个从内联形式提升上来的哈希,会向 kevy-map 要它能给的最小一张表——十六个槽——不管这行是三列还是十二列。
它的签名是:一行的固定开销与它有多少列无关。实测五万行,每行 RSS:三列 1699 字节,七列 1694,十二列 1713——而它们的载荷相差 91 字节。
核心思路
一行声明过的行变成一次分配:
[ncol u16][存在位图][end_1 u16] … [end_n u16][各列字节 …]列的顺序来自声明,所以不存字段名——它们本来就在目录里。缺失的列只花一个 bit。这一切在协议层完全不可见:HGET、HSET、HDEL、HGETALL、HRANDFIELD、HSCAN 以及字段 TTL 系列动词的回答和以前一模一样。
两种情况会让某一行换回通用形式,不报错也不丢值:写入一个表没有声明的列,以及这行的值超出 u16 偏移能寻址的范围。打包行是一个尺寸类别加一份声明,不是一个类型。
怎么打开
[server]
packed_rows = true或 KEVY_PACKED_ROWS=1,或在运行时:
CONFIG SET packed-rows yesCONFIG SET 改的是运行中的配置,不是文件——重启读的是文件,所以一台在运行时被要求打包的服务器,重启后是不打包的。
声明一张表时已经存在的行,由回填在 shard tick 上分批转换,所以对着活的键空间发声明不会把它卡住。
它省下什么,代价是什么
发布机上实测,两百万行、七列、其中一列 400 字节,三轮交错,同一个二进制只差一个开关:
| AOF 模式 | 关 | 开 | |
|---|---|---|---|
| 关 | 5,504 | 4,760 | −13.5% |
everysec | 5,824 | 5,527 | −5.1% |
always | 5,882 | 5,137 | −12.7% |
| 开了分层 | 5,221 | 5,460 | +4.6% |
单位是每 MB 源 CSV 占用的常驻内存 KB。装载吞吐不变(相差不到千分之一)。点查和索引查找不变。
列表翻页贵约 7%(p50 145 → 155 µs)。打包行靠扫描自己声明的列名来找一列——一把短切片——这正是「不要每行一张哈希表」的代价,而翻页这个形状要在二十行上各取一列。
顺序,它比上面任何一项都重要
省不省得下来,取决于表是什么时候声明的。
| 顺序 | 关 | 开 | |
|---|---|---|---|
| 先声明,后装载 | 1,663 | 1,274 | −23.4% |
| 先装载,后声明 | 1,663 | 1,721 | +3.5% |
单位是每行常驻字节,五十万行,无索引。先声明意味着每一行从建起来就是打包的,通用形式根本没被分配过。后声明意味着每一行先以通用形式建起来,回填再在它要替换的那张表旁边分配一段打包缓冲、然后释放那张表——而回到进程手里的,只有分配器愿意还的那部分。
两种顺序的峰值一模一样,这正是要点:不同的不是表示法,是历史。
本页的两组测量彼此不一致,而且为它给出的那个解释已经被实测推翻了。 上面那张 AOF 模式表同样是「先装载后声明」,它省 13.5%;而这张表里同一个顺序花 3.5%。最显然的嫌疑是索引:基准那张表声明了索引,而索引自己的回填正好在打包回填刚刚释放完内存的地方大量分配,如果分配器把那些表拿去复用而不是攥着,差异就说得通了。
实测,同一个探针加上索引声明,三轮取中位数:
| 先装载后声明 | 关 | 开 | |
|---|---|---|---|
| 无索引 | 1,663 | 1,721 | +3.4% |
| 带索引 | 1,828 | 2,186 | +19.6% |
索引让它更差;规模也不是(两百万行同样是 +3.5%);分片数与 AOF 设置两边一致;把批量装载换成逐条往返也毫无差别(+3.6% 对 +3.5%)。
答案是查询阶段。 基准在装载之后跑四种查询形状各 5000 次,而此前没有任何探针跑过哪怕一次。补上之后,三轮取中位数:
| 先装载后声明,带索引 | 关 | 开 | |
|---|---|---|---|
| 不跑查询 | 1,848 | 2,077 | +12.4% |
| 跑查询 | 2,207 | 2,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,前后各一次。这里刻意没有任何动词去报告存储形式:一个据此分支的客户端,会依赖上它绝不该依赖的东西。