1. 报错现场:一张截图背后的真相
前阵子有个同事发来一张截图,问我说:"群里有人说Redis集群不支持Lua脚本,是不是真的?"我点开一看,线上日志里赫然一行:
CROSSSLOT Keys in request don't 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:10086,KEYS[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计算时就会只取第一个{和第一个}之间的内容作为有效部分,其余前缀后缀全部忽略。比如下面这几个key:
user:{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 don't 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后回退到EVAL |
| READONLY You can't 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这条线讲透了。