为什么没人告诉我Redis哈希槽的事?

Lobsters Hottest 工具

摘要

作者解释了他们如何通过利用Redis哈希槽来正确处理Redis集群中的批操作,从而优化了配送服务的路由引擎延迟。

<p><a href="https://lobste.rs/s/nwg2ha/why_didn_t_anybody_tell_me_about_redis_hash">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/23 18:51

# 为什么没人告诉我哈希槽的存在 来源:https://blog.verygoodsoftwarenotvirus.dev/posts/2026/09/23/why-didnt-anybody-tell-me-about-hash-slots/index.html 我负责开发一个服务,该服务为零工经济配送应用决定将配送任务提供给哪位骑手。当你打开应用要求将某物送达时,某处的代码必须决定应该将任务提供给哪位骑手。我们就是执行这些分配的部门。 出于保密要求,我无法详述决策中涉及的无数参数,但有一个明显因素无需深入了解也能推断——那就是距离。为了做出合理匹配,我们需要知道**每个人实际距离多远**。直线距离会告诉你过河需要十分钟,即使河上根本没有桥。为此我们依赖路由引擎,它也是造成我们自身延迟的最大元凶。我们有必须严格遵守的特定延迟目标。我们基本上总是在试图在设定时间内清空一个桶,一旦桶溢出,所有人都会被淋湿。 需要提前说明:出于保密协议,我无法透露工作中的具体数字或条款。我可以说某个服务速度大幅提升,但不能说它从600毫秒降到了100毫秒这类具体数据。 ## 问题的形态 一个繁忙区域需要处理海量路线估算来完成单次配送分配,只要业务持续运行,这种需求就会不断重复。应对这种规模始终是个挑战。 这些估算数据的价值在于其高度重复性。住在三个街区外的邻居去杂货店的时间与我相差无几,我们开车去杂货店和去其停车场内的加油站也感觉不出区别。而餐厅则完全不会移动。相隔一个街区的两位骑手会产生几乎相同的路线请求,三十秒后他们从新位置又会产生两个新请求。如果在我们和路由引擎之间没有任何缓存层,你必然会在以分钟为单位的周期内重复计算相同距离。 进程级缓存在这里同样行不通。计算估算值的进程很少是接下来需要该值的进程,因此缓存必须共享。 ## 初级方案 基于原始坐标进行缓存是行不通的。你需要使用 H3 (https://h3geo.org/) 来去重坐标。H3 将全球划分为不同尺寸的六边形网格,允许你从给定的坐标对获取对应的六边形 ID。分辨率就像个调节阀:六边形尺寸太大导致命中率提高但估算精度下降,尺寸太小则相当于用另一种坐标标识符替代了原坐标。 因此我最初的设计是:键为 `H3_ID` 对,值为估算结果,写入路径使用 `MSET` 命令。看起来很简单,对吧? ## 但没那么简单 我们使用 Redis **集群**,而集群必须均匀分布键值对。Redis 会创建 16384 个哈希槽并分配给集群中的节点。像 `MSET` 或 `MGET` 这样处理多个键的命令,只有当所有键都映射到同一个槽时才有效。 我通过分析链路追踪发现了这个问题。单次读取操作竟然显示为数十个**单独的 `MGET` 追踪 span,每个仅处理一个键**。考虑到 OpenTelemetry 收集器几乎肯定会丢弃大量相关 span,实际情况可能更糟。我们的最大读取延迟指标远高于预期最差情况。这就是我了解到哈希槽存在的契机。 ## 向我解释哈希槽 键会被映射到哪个槽是由 Redis 执行 `CRC16(key) mod 16384` 决定的。两个仅差一个字符的键会被分配到完全不同的槽,这对批量操作是灾难性的。 我的每个键都包含唯一的六边形 ID 对,这意味着它们几乎永远不会落在同一个槽中。多键命令只能针对单个槽,因此设计良好的客户端会将你的单个包含大量键的 `MGET` 命令拆分,按每个键所属的槽进行分组,然后为每组建立独立的网络连接。考虑到我给的数据结构,这确实是正确的处理方式。 修复方案在于确保我们提供的键不会分散在太多不同的槽中。写入路径也存在相同问题。 ## #标签 Redis 为解决此问题提供了专门的逃逸通道——哈希标签(hash tag)。如果键包含花括号包裹的文本块,Redis 将只对花括号内的文本进行哈希,忽略键的其余部分。这为我们提供了强制将特定键映射到特定槽的调节阀。 (图示说明:客户端进程向三个主节点发送 12 个路由键,原始情况下每个键独立哈希后分散到不同槽,导致 12 次往返;使用 `{routing:v1:anchor}` 共享标签后,相同键被强制归并到 3 个槽,实现 3 次并行往返。) 关键在于花括号内的内容。虽然我从未拥有过比特币,但这个问题让我联想到比特币区块链如何在区块中嵌入无意义数字以使最终哈希值符合特定属性。我们本质上需要类似机制,但对输出哈希的要求简单得多。我选择通过暴力计算找到一个整数作为标签。 标签实际上是类似 `{routing:v1:}` 的模板,启动时我们从零开始迭代,每次计算完整标签的哈希值来确定它落入哪个槽,直到每个节点都获得所需数量的标签。 一旦标签开始发挥作用,数千个键的批量操作就能被拆分为少量合法的多键命令,精准定向到特定节点。 (图示说明:5 个主节点各分配 4 个标签槽位,通过计算 `routing:v1:%d` 模板的哈希值分配槽位。) ## 在扇出之前先对键排序 每个 Redis 节点在单线程中执行命令,因此针对同一节点的 20 个并发请求本质上形成队列。虽然扇出优化减少了 `MGET` 请求总量,但未能避免向特定节点发送过多请求。 (图示说明:将 24 个键分块后,若不按槽排序,会导致多个分块同时请求同一节点,形成排队;而先按槽分组则能实现真正的并行处理。) 如果分块不按目标节点组织,多个分块会同时指向同一节点,该节点将顺序执行这些请求,而其他节点则处于空闲状态。因此在任何数据发出之前,我们会先在本地计算每个键的槽位并按槽分组。这样扇出操作就基于节点而非任意分块,确保每个请求都在进行有效工作。 ## MSET 不支持过期 缓存的路线估算必须设置过期时间,而 `MSET` 命令没有过期参数。常规变通方法是使用管道批量发送 `SET ... EX` 命令,但这将单个命令转变为数千个命令。或者可以先 `MSET` 再执行 `EXPIRE`,但这不仅使命令数量翻倍,还存在窗口期风险——如果两次操作间发生崩溃,键将永久残留在缓存中。 最终解决方案是通过 `EVAL` 命令要求 Redis 执行脚本。 ## JSON 并非免费 最后这个教训回想起来有些尴尬。缓存值最初采用 JSON 格式,因为这是我惯用的方式。由于数据量小,我没觉得这是什么大问题。但在我们的规模下,它消耗了宝贵的 CPU 时间。将值格式切换为逗号分隔值后性能显著提升。 ## 我们从中领悟了什么? 这次探索中获得的所有认知,都源于分析前序步骤为何出现意外失败。作为 Go 语言狂热者,我自然崇拜 Rob Pike,特别是他的编程五原则 (https://web.archive.org/web/20260318104226/https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.html)。我近期反复思考这些原则: > **原则二:** 度量。未测量前不要调优性能,即使测量后,除非代码某部分成为整体瓶颈,否则仍无需优化。 > **原则三:** 当 *n* 较小时,花哨算法往往更慢,而 *n* 通常较小。花哨算法常有巨大常数项。除非确认 *n* 会经常变得很大,否则不要过度设计。 我曾测量到与路由服务的交互是主要瓶颈,以为与 Redis 交互采用简单算法即可解决。但我忽略了对我们的场景而言 *n* 稳定地保持在大数值,需要比预想更精巧的算法。 当我首次遇到上述吞吐量问题时,我决定回退一步编写基准测试工具。这些测试程序让我能在调整参数的同时度量吞吐量变化。它们帮助我在将方案投入生产前验证每种方法的实际效果,并准确评估这些日益复杂的代码带来了怎样的性能收益。 事后看来,我应该先构建基准测试工具。我入行时主要在初创公司工作,默认模式是“先上线再修复”。基准测试意味着需要测试工具,工具需要模拟生产环境输入,而所有这些都需要向漠不关心的团队解释为何两点工单变成了五点工单。 在编程进入“后 Claude 时代”的今天,创建此类基准测试工具只需提供规格说明并等待十分钟,这种延迟已不可接受。我最近开启新项目时都会先构建完善的基准测试框架,这为我带来了巨大收益,现在正努力将其形成习惯。

相似文章

Redis 不是一个通过 TCP 交互的映射结构

Lobsters Hottest

这篇博客文章讨论了使用 Redis 和 H3 六边形坐标为零工经济配送应用中的路由引擎估计提供缓存解决方案,并着重介绍了 Redis 集群键分布和多键命令所面临的挑战。

赞美memcached

Hacker News Top

作者主张使用memcached而非Redis作为缓存层,强调其简单性、易于处理故障、以及直截了当的集群功能,与Redis的功能膨胀以及被误用作持久化数据库的倾向形成对比。