为什么没人告诉我哈希槽的事——非常好的软件,不是病毒

零工配送平台用 H3 六边形加 Redis hash tag 缓存路径估算,把散落 16384 个槽的多 key 请求收敛到同一槽。

中文
复制

我在一家零工经济配送平台做一项服务,负责决定把您的订单派给哪位骑手。您打开应用、下单请求送货,某处的代码就得判断该把这一单派给哪位骑手。发出这些派单的就是我们。具体决策涉及哪些参数,我不便透露,但有一个相当明显的参数,不用了解太多、从外部也能推断出来,那就是距离。要做好匹配,我们得知道每个人到底有多远。直线距离会告诉你,过一条没有桥的河需要十分钟。为此我们依赖一个路径规划引擎,它也是我们自己延迟的最大来源,远超其他因素。我们有非常明确的延迟目标必须守住。基本上我们一直在努力在固定时间内清空一个桶,桶一溢出,所有人都得遭殃。先说明一下,我不便透露工作中使用的具体数字或术语。我可以说某个服务大幅提速了,但不能说它从 600ms 降到了 100ms,诸如此类。

问题的形态

一个繁忙区域在单次派单过程中需要大量路径估算,而且只要业务还在运转,就需要持续不断地获取这些估算。应对这种规模是个长期的难题。这些估算有个有用的特点:它们经常是重复的。离我三栋房子的那个人去杂货店,花的时间跟我相比不会有明显多或少,而且我俩谁也分不出开车去那家杂货店和开车去它停车场的加油站有什么区别。至于餐厅,它们根本不会移动。相隔一个街区的两位骑手会产生两个几乎相同的路径请求,三十秒后,从两个新位置又会产生两个。如果在我们和路径规划引擎之间没有任何缓存层,你必然在重复劳动,以固定的节奏反复计算那些实际上相同的距离,一算就是好几分钟。进程内缓存在这里也不行。计算某个估算的进程,很少就是下一个需要它的进程,所以缓存必须共享。

朴素的做法

直接拿原始坐标做缓存根本行不通。你得先用 H3 把坐标去重。H3 把世界切成大小不一的六边形,给定一对坐标就能查出所在六边形的 ID。分辨率是一个可调的杠杆。六边形太大,命中率更高,估算却更不准确。六边形太小,等于用一个坐标标识符换掉另一个。所以 key 是 ::,value 是估算值,写入路径是一个 MSET。小菜一碟,对吧?

没那么简单

我们用的是 Redis 集群,而集群必须把 key 和对应的 value 均匀铺开。Redis 会创建 16,384 个哈希槽,再把它们分配给集群里的各个节点。像 MSET 或 MGET 这种涉及多个 key 的命令,只有当所有 key 都落在同一个槽里才有效。我是看 trace 才发现的。一次读操作,在 trace 里表现为几十个各对应单个 key 的 MGET span。而这还是在 Otel collector 几乎肯定已经丢掉大量相关 span 的情况下。我们的最大读延迟指标远高于我们预期的最坏情况。我就是这么知道槽这个东西存在的。

槽是怎么回事

一个 key 落在哪个槽,取决于 Redis 执行的 CRC16(key) mod 16384。两个只差一个字符的 key,会落进毫不相干的槽里,这对批处理是灾难性的。我的每个 key 里都带一对唯一的十六进制 ID,所以它们实际上永远不会落进同一个槽。多 key 命令只能针对一个槽,于是一个写得好的客户端会把你一次包含大量 key 的 MGET 按各自所属的槽分组,每组单独走一条连接。就我喂给它的东西而言,这么做是对的。解决办法就是交给它不会散落在那么多不同槽里的 key。写入路径也有同样的问题。

#hashtags

Redis 正好为这种情况留了一个后门,叫 hash tag。如果 key 里有一段用花括号包起来的文本,Redis 就只对花括号内的文本做哈希,忽略 key 的其余部分。这就给了我们一个把 key 强行塞进指定槽的杠杆。

客户端进程

primary 1

primary 2

primary 3

#01

#02

#03

#04

#05

#06

#07

#08

#09

#10

#11

#12

1/4 · 一批 key,仍处于 0 次往返的过程中

89283082a53ffff:892830828efffff:9

十二个路由键,3 个主节点。整体做哈希,每个键各占一个槽位,一次 MGET 变成 12 次往返。把共享标签用花括号包起来,只有花括号内的内容参与哈希,于是同样的十二个键塌缩到 3 个槽位:3 次往返,每个节点一次,同时进行。

关键在于花括号里放什么。我从没持有过比特币,但这个问题让我想到比特币区块链如何往区块里塞无意义的数字,好让最终哈希满足某种性质。我们这里需要的本质上就是这套东西,只是对输出哈希的要求简单得多。

我选了一个整数,像区块链矿工那样靠暴力搜索找出来。标签其实是个模板,大概长这样 {routing:v1:},启动时我们从零往上遍历,每次对整个标签做哈希,看它落在哪个槽位。一直找到每个主节点都拿到足够数量为止。

标签一旦开始干活,一批几千个键就能拆成少数几条合法的多键命令,每条精确指向一个节点。

n = 0, 1, 2, … 直到每个主节点都有 4 个 12345 槽位 0 16383

主节点 1 0–3276 主节点 2 3277–6553 主节点 3 6554–9830 主节点 4 9831–13107 主节点 5 13108–16383 已尝试 0/28 · 跳过 0 0 / 20 保留 anchors = [ ]

标签模板是 routing:v1:%d。每个整数都按 Redis 实际会用的方式做哈希,落在拥有该槽位的主节点上。如果那个主节点还有空位,整数就保留;没有就丢弃,继续往下走。5 个主节点各要 4 个,需要 28 个整数,其中 8 个落在了已经满了的地方。什么都不存储:任何进程对同一集群跑同样的遍历,都会得到同一份列表。

扇出之前先给键排序

每个 Redis 节点都在单线程上执行命令,所以 20 个并发请求打到同一个节点,实质上就是排队。塌缩扇出减少了我们必须发出的 MGET 请求数量,但并没有阻止我们拿这些请求去轰炸某个节点。

  1. 3 条命令
  2. 3 条命令
  3. 3 条命令
  4. 2 条命令
  5. 2 条命令
  6. 3 条命令

primary 1 primary 2 primary 3 0123456 命令次数 · 16 条命令 0.0 完成

24 个带标签的 key,3 个 slot,3 个 primary,每条命令无论携带多少数据,往返开销都一样。把列表切成块,每块里仍然含有属于多个 slot 的 key,于是客户端会把它拆开,每个 primary 每块各拿到一条命令,依次执行。先按 slot 分组,则每个 primary 只有一条命令,全部同时在途。块的大小就是唯一的旋钮,而它转的方向是反的:切得越细,命令越多——这就是为什么把任务分散到更多 goroutine 上,换来的反而是更慢的读取。

如果这些块不是按目的地组织的,其中好几块会同时指向同一个节点,那个节点只能一条接一条地执行,而其他节点闲着。所以在任何数据发到网络上之前,我们先在本地算出每个 key 的 slot,再按 slot 分组。这样 fan-out 是分散到各个节点上,而不是分散到任意切分的块上,每个请求都在做有用的工作。

MSET 不支持过期

缓存的路由估算值必须有过期时间,而 MSET 没有过期参数。

常见的变通做法是给每个 key 流水线一条 SET ... EX,一条命令就变成了几千条。或者你可以 MSET,再在第二轮 EXPIRE,这会让命令数翻倍,而且在两轮之间留下一个窗口:一旦崩溃,key 就永远滞留在缓存里。

最后的答案是改用 EVAL 让 Redis 执行一段脚本。

JSON 不是免费的

最后这一条事后看很丢人。缓存的值一开始是 JSON,因为我一直这么干。数据量很小,我没觉得这个选择会有多大影响。但在我们的规模下,它占掉了宝贵的 CPU 时间。把值改成逗号分隔的格式后,情况改善了很多。

那么,我们学到了什么?

这件事里我学到的每一点,都来自搞清楚上一步为什么会以一种我没预料到的方式失败。

作为一个 Go 狂热爱好者,我自然对 Rob Pike 心怀敬意,尤其是他的编程五条规则。最近我一直在反复琢磨这几条:

规则 2。 先测量。在测量之前不要为了速度调优,即便测了,除非某一部分代码的开销压过其余所有部分,否则也别调。规则 3。 花哨的算法在 n 很小时很慢,而 n 通常都很小。花哨的算法常数项大。在你确知 n 经常会很大之前,别搞花哨的东西。

我测过,和路由服务的交互是我们最大的瓶颈,于是以为用简单算法对接 Redis 就是我的那杯茶。我忽略了:对我们来说 n 稳定地很大,要完成任务,我需要一个比预想中更花哨的算法。

最初遇到上文描述的吞吐量问题时,我决定退一步,写一些基准测试框架——那种可以开关各种杠杆、测量随之而来的吞吐量的玩具应用。这些框架让我能在把方案投入生产之前逐一验证,也能诚实地回答:这套越来越复杂的代码,究竟买到了什么样的性能。

事后看,我应该先搭基准测试框架。我在这行最初的经验大多来自刚起步的创业公司,那里的默认做法是先上线、后修。基准测试意味着要搭框架,搭框架意味着要造出接近生产的输入,而这一切又意味着要向一群根本不在乎的人解释:一个 2 点的工单怎么变成了 5 点。

在 Claude 之后的编程时代,搭这样一个基准测试框架不过是写份规格、等上 10 分钟的事,我觉得再拿这个当借口就说不过去了。我现在开新项目会先搭起完备的基准测试框架,收益极大,所以正努力把它变成习惯。

来源: verygoodsoftwarenotvirus.dev← 返回首页