Redis 不是一个通过 TCP 交互的映射结构
摘要
这篇博客文章讨论了使用 Redis 和 H3 六边形坐标为零工经济配送应用中的路由引擎估计提供缓存解决方案,并着重介绍了 Redis 集群键分布和多键命令所面临的挑战。
<p><a href="https://lobste.rs/s/sfhrrp/redis_is_not_map_you_talk_over_tcp">评论</a></p>
查看缓存全文
缓存时间: 2026/09/23 06:46
# Redis并非通过TCP通信的字典
来源:https://blog.verygoodsoftwarenotvirus.dev/posts/2026/09/22/redis-is-not-a-map-you-talk-to-over-tcp/index.html
我从事的服务负责在零工经济配送平台中为骑手分配订单。当你打开应用请求配送时,系统中的某段代码必须决定将任务提供给哪位骑手——而我们正是执行这些分配决策的模块。虽然不便透露决策涉及的无数参数,但有一个显而易见的外部可见因素:距离。为实现精准匹配,我们需要知道“所有骑手的实际距离”。直线距离会误判河对岸的骑手距离过近,因此我们真正需要的是行驶时间。为此我们依赖路径规划引擎,而它正是我们系统延迟的主要来源。它的响应速度直接影响我们的处理效率,进而影响应用体验。
## 问题的本质
繁忙区域需要海量的路径估算来完成单次任务分配,且只要业务运行就必须持续重复此过程。处理规模是刚性需求而非优化目标(后文详述)。这些估算值的显著特点是经常重复——隔三条街的邻居去杂货店的耗时与我相差无几,我们甚至无法区分开车去杂货店和去其停车场加油站的时间差。餐厅位置始终不变,相隔一个街区的两位骑手会产生几乎相同的路径请求,三十秒后从新位置又会产生两个新请求。若在路径引擎前缺少缓存层,必然导致大量重复计算,周期性地反复计算实际相同的距离数据,有时持续数十分钟。
进程内缓存无法满足需求:计算路径的进程很少是下次需要该数据的进程,因此缓存必须跨进程共享。我通常避免机械式地使用缓存——经验证明这常被实现者当作“提速按钮”。但此场景确实符合缓存的适用条件。
## 朴素实现方案
基于原始坐标缓存毫无意义,必须使用H3系统(https://h3geo.org/)对坐标去重。H3将地球按不同分辨率划分为六边形网格,为包含指定坐标的网格提供稳定ID。将起点和终点映射到网格后,同一网格内的所有坐标对都可折叠为同一缓存键。分辨率相当于精度调节旋钮:低分辨率命中率高但答案粗糙,高分辨率答案精确但适用范围极窄。由于分辨率直接编码在键中,不同分辨率可在缓存中共存,切换时无需清空。
因此键格式为`{起点哈希}:{终点哈希}`,值为路径估算结果,写入操作使用`MSET`命令。批量计算,批量写入,我本以为可高枕无忧迎接规模化挑战,实际却毫无扩展性可言。
## 并非如此简单
我们的Redis采用集群模式,集群将键空间划分为16384个哈希槽分配至主节点。多键操作(如`MSET`)要求所有键必须落在同一槽位。通过链路追踪发现问题:单次读取竟显示为**数十个独立`MGET`调用,每个仅对应单个键**(实际数量可能更多)。根本原因是追踪记录的总键数与实际子调用数量严重不符,Otel收集器很可能过滤了部分数据。最大读取延迟指标远高于预期最差值,至此我才意识到哈希槽的存在。
## 深入解析槽位机制
键的槽位分配既非配置项也非随机结果,而是通过`CRC16(键) mod 16384`计算。这意味着仅差一个字符的键可能落入毫不相关的槽位——对均匀分布有益,对批量处理却是灾难。我的每个键都包含不同的起点网格和终点网格,理论上任意两个键落入同一槽位的概率仅1/16384。
客户端的行为完全合理:多键命令只能访问单个槽位,因此合规客户端会将大量键按槽位分组,每组单独发送。面对我提供的键结构,这正是唯一正确的做法。不可能通过配置优化实现“单个键对应单个`MGET`”的高速模式——问题不在于客户端配置,而在于键分布过于分散。
写入路径存在相同问题:要么触发`CROSSSLOT`错误,要么每个键都需要独立`SET`操作和网络往返。
## 巧用哈希标签
Redis为此提供了哈希标签机制:若键包含`{}`括起来的文本片段,则仅对括号内容进行哈希计算。这意味着可以刻意控制键的槽位归属。
```
客户端进程 主节点1 主节点2 主节点3
#01 #02 #03 #04 #05 #06 #07 #08 #09 #10 #11 #12
```
图示:一组键在**0**次网络往返内完成传输
`89283082a53ffff:892830828efffff:9`
十二个路径键分布在三个主节点上,直接哈希时每个键独立成槽,单次`MGET`变为12次网络往返。使用共享标签后仅对括号内内容哈希,同样十二个键可折叠至3个槽位,实现并行访问。
关键陷阱在于标签内容的选择:按起点网格设标签虽符合直觉,却会导致高峰时段某个网格(对应单个标签、单个槽位、单个节点)成为热点,其他节点闲置。真正需要的是“任意性”——标签应不承载语义,确保数据均匀分布。因此标签被设定为通过暴力搜索获得的整数。
标签本质是模板(如`{routing:v1:}`),启动时从0向上迭代`n`,每次将完整标签哈希后确认其所属主节点。若该节点仍有空间则保留整数,否则尝试下一个,直至所有节点达到配额。最终得到一组“已知有效”地址:每个节点配置指定数量的整数,刻意分散在所有节点。
任何特定整数都无特殊意义,它只是第一个哈希后落入有空闲节点的数字。两个节点各需两个整数时可能得到`1,2,3,5`;若各需三个,下一个可能是8和11。结果完全取决于集群拓扑结构,每次进程启动都会确定性地重新计算。
## 实际运行示例
无法心算整数落位情况,以下是真实运行案例:从0开始逐个整数插入标签模板,使用与Redis相同的CRC16哈希算法,确认其归属主节点。若节点仍有空位则保留,否则跳过继续下一个。
```
主节点数5 每节点标签数4
n = 0, 1, 2, ... 直至所有主节点分配4个槽位
槽位范围:
主节点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的哈希方式计算。若分配的主节点仍有空间则保留,否则丢弃。五个主节点各需四个整数时共需尝试28个,其中8个落入已满槽位。
此过程不产生存储:任何进程对同一集群执行相同遍历都会得到相同结果。保留的整数看似随机,实为CRC16计算结果恰好满足条件的数字。初期几乎所有整数都被保留(各节点都有空间),仅在末期集群近满、寻找最后几个空位时才开始大量丢弃。改变主节点数量将完全改变结果,这正是重新分片对此类方案的实质影响。
## 扇出前的键排序
折叠扇出操作修复了span数量问题,但还有第二个关键原因:**并发请求落入的节点决定了并发是否有效**。
```
每组键数4
1. 3个命令 2. 3个命令 3. 3个命令 4. 2个命令 5. 2个命令 6. 3个命令
主节点1 主节点2 主节点3
命令时间轴: 0 1 2 3 4 5 6
共16个命令 **0.0**秒完成
```
24个标签键分布在3个槽位、3个主节点,每个命令的网络往返耗时相同。若简单分组,每组仍包含多个槽位键,客户端需拆分后依次发送至各主节点。若先按槽位分组,则每个主节点仅需一个命令,所有请求可并行发送。
分组大小是关键调节参数,且方向相反:分组越细生成的命令越多,更多goroutine反而降低读取速度。朴素做法是将键列表分组后分配至goroutine,这看似并行实则未必——若分组未按目标节点组织,多组可能同时请求同一节点,导致串行执行,吞吐量上限受限于最慢节点的处理能力。因此实际操作前,我们会本地计算每个键的槽位(仅需快速CRC16计算),按槽位分组后再扇出,确保所有并发请求作用于不同节点。
## MSET不支持过期时间
这点最令我困扰:缓存路径估算必须设置过期时间。`SET`支持`EX`参数,但`MSET`不接受任何参数,也不存在`MSETEX`命令。要么接受批量写入,要么接受过期时间,Redis不可兼得。常规解决方案是为每个键管道传输`SET ... EX`命令,但会将单次命令变为数千次操作。或先`MSET`再`EXPIRE`,但这会使命令数翻倍,且崩溃可能导致键永久滞留缓存。
最终方案是发送Redis脚本:`EVAL`在节点原子执行Lua脚本,可实现`MSET`拒绝支持的所有功能。
## JSON并非无代价
事后想来这点令人尴尬。缓存值最初采用JSON格式(毕竟只是时长和距离两个数字,使用最便捷的序列化格式),但在规模化场景下,“最便捷”与“最高效”不再等同。每次读取都需要完整JSON解码,消耗大量CPU时间。因此值改为CSV格式:`412.3,5120.7`,更简洁,无字段名,无需反射,解析只需`strings.Cut`和两个`strconv.ParseFloat`调用。
## 初始阶段的盲点
这些优化并非一蹴而就,而是逐步演进的产物,每一步都源于前一步以意外方式失败的经验。作为Go语言狂热者,我自然推崇Rob Pike的编程五原则(https://web.archive.org/web/20260318104226/https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.html),其中两条精准概括了我的历程:
> **原则2.** 度量。除非测量,否则不要调优速度,即使测量后,除非某部分代码显著拖累整体,否则仍不需调优。
> **原则3.** 当`n`较小时,花哨算法往往更慢(而`n`通常较小)。花哨算法存在巨大常数项,在明确`n`将持续较大前,避免使用花哨方法。
我以原则3为许可,完全忽略了原则2:从未基准测试,仅编写简单实现,本地运行良好便直接上线。忽略的关键点是:我们的工作负载中`n`并非“通常较小”,而是“稳定庞大”。原则3附带前提条件:“在明确`n`将持续较大前”——我本清楚这点,却将通用原则错误应用于已有数据却懒得查看的特定场景。
巨大的读取耗时本可通过现有监控面板提前发现(远早于编写代码),这本是原则2能避免的问题。我认为原则2常被狭义理解:度量不仅决定优化价值,更用于识别系统所处状态。本文所有改进都源于最终查看了本已存在的监控数据。
跳过度量的真实原因是:过去我认为度量是额外工作。我主要在初创公司起步,默认模式是“先上线后修复”,基准测试意味着搭建测试框架、模拟生产负载,而这一切都需要向团队解释为何简单任务变成复杂工程。因此我始终未养成基准测试习惯。
但此借口在后Claude时代已过时:向模型请求基准测试仅需一分钟,生成真实规模的模拟输入正是其擅长领域。不知行业整体是否转变(工时估算法则可能依然盛行),但我司已完全转变。识别系统状态的成本已降至“未知成为主动选择而非无奈妥协”。
但我真正想回去告诉自己的与基准测试无关:我曾对Redis持有错误的心智模型,将其视为通过TCP通信的字典。此模型在页面加载时按用户ID获取会话的场景下完全适用(这正是多数人的Redis用法),却完全不适用于我们的场景。本文每个问题都是“字典模型”悄然失效之处。
字典模型并非错误,只是作用范围有限:它准确描述单个键的单次往返操作,也是所有教程展示的范例(因为绝大多数场景确实如此)。但突破此边界后,其简化之处开始暴露问题:若所有请求落入同一线程,并发即失去意义;TTL是特定命令的属性而非存储特性。这些都非晦涩知识,文档明确记载,却从未在按ID获取会话的场景中出现。我怀疑这才是普遍真相。
相似文章
为什么没人告诉我Redis哈希槽的事?
作者解释了他们如何通过利用Redis哈希槽来正确处理Redis集群中的批操作,从而优化了配送服务的路由引擎延迟。
Redis 与野心的代价
本文批评了 Redis 最近的战略方向,重点指出了许可变更引发的冲突、功能冗余泛滥,以及其向“AI 上下文引擎”定位的转变。文章分析了宏大的企业目标如何影响了项目的开放性和简洁性。
Show HN: Redis City – 在交互式3D模型中探索Redis的工作原理
Redis City 是一个交互式3D模型,允许用户以视觉化和引人入胜的方式探索和学习Redis数据库的工作原理。
赞美memcached
作者主张使用memcached而非Redis作为缓存层,强调其简单性、易于处理故障、以及直截了当的集群功能,与Redis的功能膨胀以及被误用作持久化数据库的倾向形成对比。
@tom_doerr: 零停机迁移Redis数据 https://github.com/tair-opensource/RedisShake…
RedisShake是一款开源工具,用于零停机迁移Redis数据,支持多种Redis版本、Valkey以及阿里云和AWS等云服务。