1. 报错现场一张截图背后的真相前阵子有个同事发来一张截图问我说群里有人说Redis集群不支持Lua脚本是不是真的我点开一看线上日志里赫然一行CROSSSLOT Keys in request dont hash to the same slot其实看到这行报错的人十有八九都会冒出同样的疑问。你明明在单机Redis上跑得顺顺当当的脚本换到集群环境就报错再加上网上各路帖子都信誓旦旦地说集群环境别用脚本集群不支持Lua很难不怀疑是Redis本身的锅。但真相是Redis集群完全支持Lua脚本只是它对脚本能触碰的key有一套强约束而大多数人压根没意识到这套约束的存在。这篇帖子我就把来龙去脉讲透从报错出现的原因到怎么改key命名、怎么验证、有哪些隐藏的坑一次说清楚。1.1 让脚本瞬间失效的CROSSSLOT报错先聊最常见的那个报错。它的完整逻辑是在集群模式下任何一条命令里出现的多个key必须属于同一个slot。注意这里不止针对Lua脚本MGET、MSET、DEL这种多key命令也一样。举个例子我在单机版上写过一个扣库存的脚本local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock 0 then return -1 end redis.call(decr, KEYS[1]) redis.call(incr, KEYS[2]) return stock单机版上跑得好好的KEYS[1]是stock:10086KEYS[2]是sold:10086两个key在脚本里一读一写互不干扰。可一旦把这段脚本原封不动搬到集群上第一次执行就爆出CROSSSLOT报错。原因特别简单stock:10086和sold:10086经过CRC16取模之后被分配到了两个不同的slot甚至可能落在两个不同的物理节点上。一个脚本要在多个节点上同时读写数据这在集群的原子性模型下不可能被允许。1.2 另一个更隐蔽的报错脚本访问了非本地key如果你小心避开了第一个坑第二个坑还埋在不远处等着你。有时候你在脚本里动态拼了一个key比如local order_key order: .. ARGV[1] local num redis.call(incr, order_key) return num调用时传入了看起来正常的key脚本运行时拼出来的order:xxx却和传入的key不在同一个slot。这时候Redis同样会拦下脚本报出另一条很经典的错误ERR Lua script attempted to access a non local key in a cluster node这个报错比CROSSSLOT更隐蔽因为它不会在你调用之前检查参数时触发而是在脚本运行过程中才被发现。我第一次撞见时也很懵脚本参数明明没问题为什么跑到一半就报错后来才明白集群下Lua脚本不仅要看传入的KEYS还要看脚本执行期间实际访问的所有key。2. 集群为什么对Lua脚本卡脖子slot路由机制拆解既然要解决问题就得先搞清楚这条规则背后的逻辑。它不是什么反人类的限制而是集群架构的必然要求。2.1 16384个slot是怎么算出来的Redis Cluster把整个keyspace逻辑上分成16384个slot。当你往集群里写入一个key时这个key会先经过CRC16算法算出一个校验值再对16384取模得到最终归属的slot编号。slot和节点之间的映射关系由集群元数据统一维护每个节点只负责其中一段连续的slot区间。这里有个常见的认知误区很多人以为slot和节点是一对多的绑定实际上单个slot只能归属于一个节点而一个节点可以管理多个slot区间。key往哪个节点上存完全由slot决定。理解了这套机制CROSSSLOT错误的根源就很清楚了。key的归属不是随机也不是任意的而是精确到slot。如果一条命令同时涉及多个key而这些key的slot不同节点根本不知道该把整条命令路由到哪一台机器去执行即使客户端强行把命令拆开分发给多个节点跨节点的原子性也没法保证。这就是为什么集群模式下多key命令必须满足同slot这个硬性前提。它的本质是集群只保证单个slot内部的原子性不保证跨slot的事务性。2.2 Lua脚本的key校验规则Lua脚本在集群里的规则比普通多key命令更严格。一个脚本在被执行之前Redis会检查调用命令时传入的所有KEYS确认它们落在同一个slot上。一旦发现不满足直接拒绝执行于是产生了CROSSSLOT报错。如果KEYS全部满足同slot要求脚本在执行过程中还会继续做校验。脚本里每次通过redis.call或redis.pcall访问key节点都会做一次slot归属检查。这里值得特别注意如果脚本访问的key不在当前节点负责的slot范围内节点会直接报错而不是悄悄把请求转发到其他节点。这种一个脚本只能访问本节点slot的设计从根本上保证了脚本执行期间不会被其他节点上的数据变化干扰。你可以把它理解成一段代码在单核CPU上从头跑到尾中间不会被其他线程打断因此读写逻辑是绝对原子的。如果允许脚本跨节点访问光是在多个节点之间同步执行状态就是一场灾难。2.3 单机版没这个问题的原因单机版为什么没有这些限制因为单机版只有一个节点所有数据都在同一台物理机器上。脚本访问任何key都等于访问本地数据当然不涉及跨节点的问题。所以在单机版上写得随心所欲的脚本换到集群后处处碰壁是非常正常的。这正好解释了一个传播很广的误解那些说集群不支持Lua脚本的人大概率是在单机版上写惯了多key脚本换到集群后没有做任何适配自然被报错劝退。问题不在Lua脚本本身而在集群对key分布的铁律。3. 绕开CROSSSLOT的正道哈希标签的实操与代价3.1 哈希标签的计算规则与改造示例要解决CROSSSLOT最标准也最省事的办法就是哈希标签hash tag。规则说复杂也复杂说简单也简单在给key命名时只要key字符串中出现了花括号对{}Redis在做slot计算时就会只取第一个{和第一个}之间的内容作为有效部分其余前缀后缀全部忽略。比如下面这几个keyuser:{10086}:profile user:{10086}:orders user:{10086}:cart三个key的有效哈希部分都是10086因此无论中间拼接了什么业务字段它们都会落到同一个slot。这正是集群下运行多key脚本的前提条件。回到刚才扣库存的例子只需要把key改造成这样stock:{10086} sold:{10086}然后脚本保持不变执行时传入这两个key问题当场解决。我见过不少团队在踩坑之后直接禁用了Lua脚本其实非常可惜加一个tag就能解决的事完全没必要因噎废食。3.2 验证脚本能否运行的三个实用命令改造完key之后别急着上线先验证一遍。我平时固定用三个命令做体检每一步都有明确的检查目标。第一步确认同槽。用CLUSTER KEYSLOT命令分别查一下两个key的slot编号redis-cli -c -p 7000 CLUSTER KEYSLOT stock:{10086} redis-cli -c -p 7000 CLUSTER KEYSLOT sold:{10086}两次返回的编号一致说明这两个key已经归一到同一个slot脚本的基本前提成立了。第二步直接执行一次EVAL验证脚本本身在当前集群环境下能跑通。这一步会把脚本从上到下完整执行一遍比单纯算slot更接近真实。第三步用集群自带的体检命令看整体分布情况redis-cli --cluster check 127.0.0.1:7000这个命令会输出每个节点负责的slot数量、key数量以及平均分布情况。如果发现某个节点的key数量明显偏多说明你的tag设计粒度有问题会拉响我下一节要讲的热key警报。3.3 热key问题用tag一时爽分布火葬场哈希标签不是银弹它有代价。代价就是数据倾斜风险。当大量key使用了同一个tag它们会被强行塞进同一个slot进而全部堆积到同一台节点上。这会造成两个直接后果第一这个slot所在的节点承担了这组key的全部读写流量CPU和内存都压力山大第二哪怕这些key的读写频率不高单点的存储容量也可能先被打满。我见过最典型的失误是把日期作为tag。有个统计功能把当天所有的PV埋点key都设计成{2024-11-05}:pv:*结果当天所有写入全部打在同一个slot的节点上高峰时段直接把那台机器的CPU拉满。日期、地区、设备类型这些低基数字段天然不适合做tag——它们的取值太少了一个值就能吞掉大量key。正确做法是尽量选择高基数、几乎每个key都不一样的字段做tag比如用户ID、订单号。user:{10086}:profile这种设计虽然把同一个用户的所有业务数据聚到了一起但不同用户之间依然会分散到不同slot整体分布依旧均匀。我更想强调的是另一个容易忽略的坑同一个tag不能跨业务复用。我见过一个项目把线上所有业务key都按user:{userId}:*来设计平时没问题结果运营团队在跑批量风控任务时为了省事用了{job}:*这种公共前缀把上百万个临时key全部塞进了一个slot当晚那个节点直接被打挂。tag的设计必须结合业务隔离来做批量任务、临时数据、核心业务数据尽量使用完全不同的命名空间。4. 脚本缓存、动态key和集群变更一系列容易翻车的角落4.1 EVALSHA遇到NOSCRIPT从单机迁到集群最容易踩的坑这个坑和slot关系不大但几乎每一个从单机版迁移到集群的项目都会撞上。单机版下用SCRIPT LOAD缓存脚本、再用EVALSHA执行已经成了标准姿势可以省去每次传输整个脚本内容的带宽开销。但集群模式下每个节点各自维护自己的脚本缓存。你在这台节点上SCRIPT LOAD了脚本不代表其他节点上也缓存了这个脚本。最典型的情况发生在主从切换之后脚本缓存在旧的主节点上主从切换后客户端连接到了新的主节点这个新节点上没有缓存过该脚本。此时执行EVALSHA就收到NOSCRIPT No matching script. Please use EVAL.为什么说这是从单机迁到集群最容易踩的坑因为单机版的主从复制机制会把脚本缓存同步到从节点很多人形成了脚本缓存是全集群共享的印象。集群模式下节点各自独立这个印象就彻底失效了。我在写代码时通常这样处理以Python为例import hashlib script local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock 0 then return -1 end redis.call(decr, KEYS[1]) return stock script_sha hashlib.sha1(script.encode()).hexdigest() try: res rc.evalsha(script_sha, 1, stock:{10086}) except redis.ResponseError as e: if NOSCRIPT not in str(e): raise res rc.eval(script, 1, stock:{10086})这段代码的核心逻辑是优先用EVALSHA捕获到NOSCRIPT错误后自动回退到EVAL。无论你用什么语言什么客户端这条回退逻辑都必须存在。有些客户端库比如Jedis、redis-py的Cluster版本已经内置了这套处理但如果你用的是自研封装或者旧版本客户端就一定要检查一下。4.2 脚本内部动态拼接key的限制第二个坑在开头埋了伏笔这里展开讲清楚。集群模式下脚本能碰哪些key不只是看调用时传入的KEYS还要看脚本执行过程中实际访问的key。如果脚本内部用字符串拼接、用ARGV参数拼出一个新key而这个新key最终落在了另一个slot就会触发开头提到的non local key报错。我在团队里给开发定过一条规矩Lua脚本里需要访问的key必须全部在调用前显式声明能不用ARGV拼key就别拼。理由就是动态拼接的key很难在调用前通过静态检查发现它往往是线上运行了几天甚至几周才会被某个特殊参数触发排错成本极高。如果确实需要动态访问key有一个折中方案让动态key也带上与传入key相同的tag。比如传入的KEYS[1]是user:{10086}:profile脚本内部动态访问user:{10086}:orders因为两边的有效tag都是10086天然同槽就能安全通过校验。但这种方式要求脚本作者对tag规则非常熟悉而且必须在脚本注释里写清楚否则后来维护的人一不小心就会踩雷。4.3 reshard与主从切换期间的边界行为集群不是静态的。扩缩容时会reshard节点故障时会主从切换。这两种操作期间Lua脚本的行为会出现一些看起来很奇怪的边界情况。reshard期间slot正在从A节点迁移到B节点某个key可能还留在A节点上但slot的归属已经被标记为迁移中。此时脚本访问这个key节点可能返回ASK重定向。虽然大多数客户端库会自动处理ASK但脚本不是普通命令重定向之后整个脚本是否需要重新执行不同客户端的处理逻辑并不一致。我的建议是脚本执行失败时统一捕获异常并重试重试会重新解析slot路由比在失败现场反复猜测靠谱得多。主从切换的影响则更多体现在脚本缓存上也就是4.1里提到的NOSCRIPT问题。此外如果脚本在旧主节点上已经执行了一半而此时发生了failover从节点通过复制得到的数据状态是执行前的还是执行后的这一点可以放心脚本执行在Redis内部是原子的要么全部生效、要么全部不生效。只要旧主节点在崩溃前完成了脚本执行并生成了复制数据数据就不会是中间态。这是Redis原子性设计给的底气咱们用Lua脚本的初衷也正是为了这份原子性。5. 分布式锁场景下的Lua集群兼容性到底怎么样5.1 加锁与解锁脚本为什么能跨过这一关很多人关心集群模式下能不能用Lua脚本写分布式锁。答案是可以而且单key锁天然没有任何slot问题。经典的加锁逻辑是 SET NX PX这本身就是一条原子命令压根不需要脚本。真正需要脚本的是解锁逻辑if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0这个脚本的作用是只有value匹配时才删除key目的是避免自己把别人刚获得的锁误删掉。它只操作一个key无论这个key被路由到哪个slot脚本都能直接执行完全不存在CROSSSLOT的问题。所以结论很明确分布式锁脚本在集群下运行是没有任何障碍的。前提是你别在同一个脚本里把锁key和业务key混在一起操作——一旦混了业务key与锁key不在同槽CROSSSLOT又来了。我见过有人为了省一次网络往返在加锁脚本里顺手把业务数据也写了结果就是集群下一次次报错。5.2 集群下单key锁的真实局限与选型虽然单key锁脚本能跑但集群环境下的分布式锁仍然有一个和Lua无关的隐患主节点宕机的时候锁可能直接失效。设想一下这个时序客户端A在主节点上拿到锁主节点还没来得及把锁数据同步给从节点就崩溃了集群控制器把从节点提升为主节点此时新的主节点上并没有这把锁的记录客户端B来加锁成功拿到锁。于是两个客户端同时持有了一把互斥锁互斥保证就此失效。这个问题在单机版上同样存在但集群里因主从切换是常态显得更尖锐。有人会想到RedLock方案向多个独立节点同时加锁。这个方案在业内一直争议很大Redis作者自己都写过文章讨论它的安全性边界工程界对它的评价也是褒贬不一。我的建议是区分业务场景来看。如果锁失效的代价可以接受——比如只是做重复请求的幂等控制大不了多写一条重复记录——单key锁加合理过期时间就够用如果是真正不能出错的互斥场景我更建议引入重量级的协调服务不要在Redis集群上强行追求极端正确性。这个选择背后是架构复杂度的问题不是Redis本身的问题。6. 几次实战后我对集群Lua的最终结论写了这么多结论其实很简单Redis集群支持Lua脚本支持得好好的。但你必须遵守集群的规矩——所有被脚本访问的key必须通过哈希标签归一到同一个slot脚本内部不要动态生成无法保证同槽的key从单机版迁移时要重点处理EVALSHA的NOSCRIPT回退设计tag时注意粒度别把低基数字段当tag用否则数据倾斜会让你痛不欲生。整理一份我压箱底的报错速查表遇到问题可以直接对照报错文本触发场景核心原因处理方式CROSSSLOT Keys in request dont hash to the same slot调用脚本时传入多个key相关key落在了不同slot用{}标签让相关key同槽ERR Lua script attempted to access a non local key in a cluster node脚本运行到一半报错脚本内动态访问了不在本节点的key动态key也带同tag或改写为显式传入NOSCRIPT No matching script. Please use EVAL.执行EVALSHA失败当前节点没有缓存对应脚本捕获NOSCRIPT后回退到EVALREADONLY You cant write against a read only replica在从节点上执行了脚本客户端路由到了只读副本强制脚本路由到master或只做只读操作最后再说一个容易被忽略的小细节使用哈希标签之后一定记得要回归测试集群的key分布。你可以间歇性地用redis-cli --cluster check检查slot是否均匀也可以在代码里对线上key做一次抽样统计。别等数据倾斜到把节点打挂的时候才发现当初那个看起来很美好的tag设计有问题。我个人踩过最痛的一次恰恰是把tag设计得过于合理为了把所有订单数据按用户聚合我把order:{uid}:*定成了全部订单key的命名规范。表面上看没问题但运营部门跑了一个风控任务把几百万个临时key全部塞进了order:{0}:*这个公共桶里那个节点当晚CPU从10%直接飙到接近满负荷。后来我把公共任务的key单独设计了独立前缀和业务tag彻底隔离才解决问题。这个故事以后有机会再展开细聊今天先把集群和Lua这条线讲透了。
阅读完成 · 觉得有帮助?