2.4 路由缓存:Redis 加速定位
ShardingSphere 自己就能算路由,为什么还要 Redis 做路由缓存?因为有一类查询「不带分片键」,算不出路由——最典型的就是按店铺查订单。这类查询要么广播,要么先查一次「维度到分片」的映射表。映射表本身也会成为热点,于是再前面加一层 Redis 缓存。这一节讲清楚二级路由的完整链路。
场景:不带分片键的查询怎么路由
订单系统里店铺查单(商家看自己的待发货订单)是高频场景,查询条件是 shop_id 而不是 order_no。订单按 order_no 分片后,shop_id 与分片号没有数学关系,只能靠一张「店铺到分片」的映射表 t_shop_shard 记录每个店铺的数据落在哪些分片。
| 查询条件 | 是否带分片键 | 路由方式 | 延迟 |
|---|---|---|---|
| order_no = ? | 是 | 本地哈希计算 | 微秒级 |
| user_id = ? | 否(查索引表) | 先查索引再路由 | 20ms |
| shop_id = ? | 否 | 查映射表加缓存 | 5ms(缓存命中) |
| pay_time 范围 | 否 | 广播 | 900ms |
映射表访问量极高(每次店铺查单都查一次),于是引入 Redis 缓存。这里的 Redis 不是在缓存业务数据,而是在缓存「路由信息」——二级路由的完整链路是:应用先查 Redis 的 shop_id 到分片列表映射,未命中再查 t_shop_shard 表,拿到分片列表后精准路由到对应分片。
方案:映射表加 TTL 的设计
二级路由的数据模型很简单:Key 是 shop_id,Value 是这个店铺有数据的分片列表。难点不在存而在「失效」——订单写入总是先发生,映射关系随后更新,存在窗口期。设计上我们做了三个约定。这里强调一个前提:映射表写入时必须记录订单号与分片的对应关系,而不仅仅是「分片列表」——列表式记录无法回答「某条订单落在哪」,对账时只能整店扫描。
| 设计点 | 取值 | 理由 |
|---|---|---|
| Key 格式 | route:shop:{shop_id} |
命名空间隔离,便于批量扫描 |
| Value | 分片号_BITMAP(16 位) | 16 片用 2 字节足够 |
| 过期时间 | 5 分钟 | 兜底过期,防止漏刷 |
| 写入时机 | 订单写入后异步更新 | 不阻塞主流程 |
| 一致性级别 | 最终一致 | 由 3.4 节对账兜底 |
店铺可能横跨所有 16 个分片(大商家),但中小商家通常只落 3 到 5 个分片。分片列表用位图而不是数组存储,16 位整数即可表示,单个 Key 的 Value 只有 2 字节,10 万店铺的映射表在 Redis 里只占几 MB。Redis 集群的选型与容量规划可以参考 Go 实战:IM 系统 的 Redis 章节,这里只需主从版即可。
配置:缓存读写与失效
查询侧的伪代码如下,先读缓存再回源映射表,回源后回写缓存:
List<Integer> shards = redis.getBits("route:shop:" + shopId);
if (shards == null) {
shards = shardMapDao.findShardsByShop(shopId);
redis.setBits("route:shop:" + shopId, shards, 300);
}
List<Order> orders = orderDao.findByShards(shards, shopId);
写入侧的失效更关键。订单写入后发一条消息,消费者把该订单所在分片加入对应店铺的位图。消息可能重复投递(幂等性由位图的或操作天然保证),也可能延迟——5 分钟 TTL 就是兜底,即使消息丢了,缓存最多存活 5 分钟。
店铺迁移(比如扩容时分变了)时必须主动失效。我们的做法是迁移任务执行前先 SCAN 删除 route:shop:* 全量 Key,改成无路雨状态强制全部回源,再在低峰期预热。
缓存预热与回源保护
缓存上线时还要解决「冷启动」问题。全新部署时 Redis 为空,所有查询都回源映射表,映射表瞬间成为瓶颈。我们的做法是上线前灌入预热脚本:把全量店铺的映射关系提前刷进 Redis,缓存命中率一上来才开放流量。
| 保护措施 | 配置 | 作用场景 |
|---|---|---|
| 预热脚本 | 上线前跑全量店铺 | 冷启动 |
| 回源限流 | 映射表 QPS 上限 2000 | 缓存大面积失效 |
| 布隆过滤器 | 记录存在的 shop_id | 缓存穿透防护 |
| 空值缓存 | TTL 30 秒 | 查询不存在的店铺 |
| 本地兜底 | 应用内缓存 10 秒 | Redis 短暂故障 |
布隆过滤器防的是穿透:如果有恶意请求拿不存在的 shop_id 反复查,每次都回源。空值缓存同理,把「不存在」这个结果也缓存 30 秒。这些手段在 PHP 实战:秒杀系统 里有更系统的讨论,核心思想都是一致的——缓存层的每一处间隙都要堵上。
为什么不用应用本地缓存
设计评审时有人提议用应用本地缓存(每个 JVM 进程内缓存路由),响应更快还没有网络开销。我们最终否决了,理由有两个。第一是路由是「全局共享」信息:店铺数据写入发生在任意一台应用节点,本地缓存之间互不可见,失效只能靠 TTL,分钟级不一致被放大成全量不一致。第二是路由变化敏感度极高:迁移期一条错误路由就造成写错库,故障半径是数据级别的。
| 缓存层级 | 一致性 | 延迟 | 适用内容 |
|---|---|---|---|
| 应用本地缓存 | 差(靠 TTL) | 微秒 | 字典、配置类 |
| Redis 集中缓存 | 最终一致 | 毫秒 | 共享状态、路由 |
| MySQL 映射表 | 强一致 | 20ms | 路由权威来源 |
只有 Redis 这一层的集中性,让失效操作一处生效全局生效。多机房部署时 Redis 也只做一个集群,跨区读路由缓存的延迟在可接受范围。任何「全局可变」的数据,都不要轻易放本地缓存。
踩坑:迁移期缓存漂移
扩容切换那天踩过最大的坑:新分片已启用、应用缓存里还是旧分片号,部分店铺的订单被写进了已迁移走的旧分片,形成了数据双胞胎(新旧分片各有一份)。事后复盘加了三道保险:迁移前清缓存、缓存 TTL 只设 5 分钟、切换期开启双写核对(旧分片与新分片写后逐条对比 1 小时)。
第二个坑是把缓存当存储。有一次运维误删 Redis 全部数据,应用瞬间从 5 毫秒路由退化到映射表回源(20 毫秒),看起来变化不大,但映射表是单库单表,瞬间成为全系统瓶颈,雪崩了近 10 分钟。缓存层任何时候都必须回源安全:回源路径要有连接池保护与限流,确保缓存全挂时系统只是变慢而不是瘫痪。此后我们给映射表回源加了令牌桶限流(2000 QPS 上限),并在应用侧加了 10 秒本地兜底缓存:Redis 短暂故障时退化为本地缓存,刷新慢一点但不会雪崩。这两条后来在 Redis 一次主从切换时真正救了场。
第三个坑是位图编码的版本兼容。初期 Value 用数组存分片号,扩容到超过 32 片的时代码里硬编码的 16 位位图会溢出。后来统一改成 Bitmap 结构并加版本号前缀,任何旧版本数据能识别能升级。涉及编码格式的数据结构,第一天就要留出版本字段。
第四个坑是预热脚本与真实流量叠加。一次新机房上线,预热脚本在跑的同时已有流量进来,两边并发回源导致映射表连接数打满。此后预热脚本统一改为「分批限速 + 只在低峰期执行」,且与正常流量共用连接池上限。
小结
二级路由是分片键之外的补充查询路径:映射表负责正确性,Redis 缓存负责性能,TTL 兜底负责一致性。设计上记住三条:缓存必须能随时全量重建、写入侧更新消息要幂等、任何分片数或分片归属变更都要配套失效方案。路由缓存的本质成本是「接受分钟级不一致,用对账换性能」。这套方案的适用前提是业务允许这一致性窗口,强一致场景请直接放弃缓存走映射表;缓存命中率的监控要纳入核心告警,它掉下来的时候往往是数据迁移的前兆。路由缓存治理是分库分表运维里少数能用低成本换来大收益的工程,建议在第一年就开始建设。
延伸阅读
- Go 实战:IM 系统:Redis 缓存治理与失效策略的完整实践
- node 实战:白板系统:协作类应用中缓存与回源保护的对照设计
- MySQL 实战:分库分表订单系统:回到本书,第三篇进入跨库查询与一致性
