ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis脚本实战:从Lua原子性到分布式锁,避开性能坑

Redis脚本实战:从Lua原子性到分布式锁,避开性能坑 做了这么多年后端我越来越觉得Redis脚本是个被低估的东西。很多人把Redis当缓存用无非就是set、get、del偶尔用一下分布式锁但一旦业务逻辑复杂起来——你想把判断更新合成一步、想避免多次网络往返、又想让多个操作要么全成要么全败这时候Redis脚本就是那个绕不开的方案。这篇文章我把Redis脚本从原理到实战完整拆一遍包含我实际踩过的坑和排查思路新手照着抄老手也能查漏补缺。Redis脚本在很多项目里其实早就被用烂了但网上的资料要么是官方文档式的生硬翻译要么是只讲单个命令的碎片文章真正能把原理、场景、坑点串起来讲清楚的并不多。我这次根据自己的实战经验把Lua脚本怎么写、为什么它能保证原子性、分布式锁和限流怎么做、脚本超时怎么处理、集群环境下有什么坑全部整理成一套可以直接落地的东西希望能帮你少走弯路。1. Redis脚本到底解决的是什么问题1.1 先搞清楚Redis脚本不是让你写复杂业务逻辑的Redis脚本最常说的其实是Lua脚本。Redis在2.6版本开始内嵌了Lua解释器你可以把一段Lua代码发给Redis在Redis进程内部去执行多个Redis命令。它不是让你在Redis里跑那种特别重的业务逻辑而是让你把原本需要在客户端用多次命令组合完成的操作打包成一段脚本在服务端一次完成。强调一下这个定位很重要因为很多人一开始容易走偏。Redis脚本适合的是多个命令必须组合在一起、并且要求原子性、或者想减少网络往返的场景。它不适合做复杂的计算、不适合处理大文本、更不应该把整块业务逻辑全塞进去。我见过有的团队把非常耗费时间的循环和字符串处理写进Lua脚本结果把Redis实例的CPU直接打满线上事故就是这么来的。1.2 没有脚本时你写业务有多痛苦回忆一下你写Redis业务的经典痛点假如要做一个扣减库存的功能正常的流程是先查库存是否够再决定扣不扣。但是查和扣是两条命令中间隔着一个网络往返很可能在你查询之后、执行扣减之前另一个线程已经把库存扣完了。这就是典型的并发问题race condition。后来大家用WATCH做乐观锁也是能解决问题但WATCH的机制是发现冲突就重试在高并发场景下重试率很高而且代码写起来也麻烦一会要MULTI、EXEC、WATCH串来串去还容易出错。更痛苦的是批量的场景。比如你要给一个用户的多个Hash字段做更新或者要同时操作多个key如果不使用脚本代码里就是一连串的独立命令每一条都有一次网络开销。QPS要求高的接口根本扛不住。这时候你才会理解Redis脚本存在的意义是什么把多条命令合成一次网络请求并且在服务端保证它们整体执行、中间不会被其他命令插队。1.3 三个核心价值一个都不能少我总结下来Redis脚本的核心价值就三条原子性脚本在Redis中是作为一个整体执行的执行期间不会有其他客户端命令插入。这比MULTI事务还彻底MULTI在执行过程中如果某条命令语法错误或者执行失败并不会自动回滚但脚本是在执行完毕后整体生效中间任何步骤出错Redis不会执行部分写入的中间结果让你看到。实际严谨一点说Redis脚本执行过程中如果遇到运行时错误已经执行的写入不会被自动回滚但脚本的原子性体现在执行期间其他命令不能插入。这一点很多人有误解后面我会展开讲。减少网络往返多条命令打包进一个脚本一次网络通信搞定。在高延迟场景下收益极其明显比如跨机房部署时一次RTT从几十毫秒降低到一次。复用逻辑通过SCRIPT LOAD可以把脚本加载到Redis内存里后面用EVALSHA执行不仅省流量还能保证同一段逻辑在多个客户端完全一致避免每个语言写出的逻辑有细微差别。2. 环境准备与最基础的操作先把锅架起来2.1 各平台安装Redis别在这上面浪费时间既然要玩脚本Redis得先装好。不同平台装法不太一样我简单说下macOS最简单的是用Homebrew执行brew install redis装完直接brew services start redis启动。也可以下载源码包自己编译但没必要brew装的足够用。Windows官方不提供原生Windows版本的Redis。最常见的做法是装WSL然后走Linux流程或者直接使用tporadowski维护的Redis Windows移植版装完是一个Windows服务挺方便。再不然用Docker Desktop一条命令docker run -d -p 6379:6379 redis:7。我个人推荐Docker因为我见过太多人栽在Windows移植版的配置差异上。Linux主流发行版都有Redis的软件包。Ubuntu/Debian是apt install redis-serverCentOS/RHEL是yum install redis。装完检查一下版本最好用6.x以上版本太低一些功能会缺。装好之后用redis-cli -v确认版本再说一句Redis在6.0版本对脚本相关的调试支持更加完善7.0开始又引入了Redis Functions如果你打算深入玩脚本尽量别用5.0以下的版本。2.2 先跑通第一个Lua脚本EVAL命令连接上Redis之后输入127.0.0.1:6379 EVAL return hello redis script 0这行命令的意思是执行一段Lua脚本脚本内容就是返回一个字符串0表示脚本不需要访问任何key。执行后会得到hello redis script好了你已经跑通第一个Redis脚本了。接下来稍微复杂一点让脚本去操作Redis里的数据。Redis在Lua脚本里通过两个函数来执行Redis命令一个是redis.call()一个是redis.pcall()。两者的区别是redis.call()如果命令出错会直接把错误抛出来终止脚本redis.pcall()则是捕获错误并返回一个Lua table来描述错误脚本可以继续执行。举个例子EVAL return redis.call(SET, KEYS[1], ARGV[1]) 1 mykey hello这段脚本往mykey里写入了hello。你可能已经注意到脚本里出现了KEYS和ARGV这两个特殊的东西这就是接下来要讲的传给脚本的参数。2.3 KEYS和ARGV的区别新手最容易踩的第一个坑在Redis脚本中你传给EVAL的参数分两种KEYS用来传Redis的key名。之所以要单独区分出来是因为Redis Cluster集群模式下key的分布是依靠hash slot的Redis需要在执行脚本之前就知道这个脚本要操作哪些key从而确保这些key都落在同一个节点上。如果你把key名混在普通参数里脚本根本没法分清楚谁是谁在集群模式下会出严重问题。ARGV普通参数可以是任意内容比如过期时间、限流的阈值等。所以正确习惯是凡是Redis的key一律走KEYS传其他纯数据参数走ARGV传。EVAL return redis.call(SET, KEYS[1], ARGV[1], EX, ARGV[2]) 1 user:1001 hello 60这段脚本给user:1001设置值hello并且60秒过期。很多人一开始图省事直接写成这样EVAL return redis.call(SET, user:1001, hello) 0在单机Redis下确实能用但这段脚本在集群环境下就是一颗定时炸弹因为Redis不知道user:1001这个key属于哪个slot直接拒绝执行。3. 四个高频实战场景直接能抄作业3.1 分布式锁最经典的入门案例说Redis脚本绕不开分布式锁。因为分布式锁是目前Redis脚本应用最广的场景没有之一。最简单的加锁方式一段脚本搞定if redis.call(SET, KEYS[1], ARGV[1], NX, PX, ARGV[2]) then return 1 else return 0 end调用时KEYS[1]是锁的key名ARGV[1]是客户端唯一标识比如UUIDARGV[2]是锁的过期时间毫秒数。这段脚本的思路是用SET命令配合NX参数保证只有key不存在时才能设置成功PX设置过期时间。如果设置了成功说明抢锁成功返回1如果key已经存在说明锁被别的客户端持有返回0。为什么不用两条命令分开做试想一下先SETNX成功再EXPIRE设置过期时间中间如果客户端崩溃或者网络断开过期时间还没来得及设置锁就一直存在了其他客户端永远抢不到。这就是经典的死锁场景。而用一条SET命令带NX和PX参数原子性地完成加锁设置过期时间就根除了这个问题。解锁同样要用脚本if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end这个脚本的逻辑是先检查锁的value是不是自己的客户端标识如果是才允许删除。不是自己的锁不能删否则你可能会把别人刚抢到的锁给误删了。这里可能会有人问为什么要用脚本我直接用GET和DEL两条命令不行吗你可以自己推演一下你GET到了value发现是自己的还没来得及DEL锁过期了另一个客户端抢到了锁这时你继续执行DEL岂不是把别人的锁删了而脚本把GET和DEL作为一体执行中间不会插入其他客户端的命令这个bug就被堵死了。3.2 接口限流固定窗口脚本限流也是脚本高频应用。最简单的固定窗口限流逻辑在某个key上记录请求次数超过阈值就拒绝。local current redis.call(GET, KEYS[1]) if not current then redis.call(SET, KEYS[1], 1, EX, ARGV[1]) return 1 end if tonumber(current) tonumber(ARGV[2]) then redis.call(INCR, KEYS[1]) return 1 end return 0调用参数KEYS[1]是限流窗口的key比如rate:user:1001ARGV[1]是窗口长度秒数ARGV[2]是窗口内允许的最大请求次数。脚本执行逻辑是如果key不存在说明是窗口内第一个请求直接设置计数为1并设置过期时间如果存在但计数没到上限就INCR加一放行如果达到了上限返回0拒绝。这个方案的巧妙之处是GET、SET、INCR这些操作合在一起整个过程无并发问题。你可能会想INCR本身是原子的但我还要判断当前值是否超过阈值判断和自增如果分开还是会有并发漏洞。脚本把判断和自增捆绑在一起从根源上消除了这个漏洞。当然固定窗口有一个边界问题窗口切换瞬间可能双倍流量。但实际业务中尤其是应对突发流量和简单防刷这个方案足够用了。如果要更平滑在Lua里做滑动窗口或者令牌桶也完全可以只是逻辑稍微复杂一点网上有很多现成的脚本篇幅原因就不展开写了。3.3 库存扣减超卖克星库存扣减是电商后端绕不开的老问题。用Redis脚本可以很优雅地解决超卖local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return 0 end redis.call(DECR, KEYS[1]) return 1逻辑非常直白读取当前库存如果是0或者key不存在返回失败否则执行DECR扣减一返回成功。你可能会想这不就是先GET再DECR吗光这两步在高并发下一定会有超卖。GET的时候还有库存但DECR之前库存已经被别人扣完了。脚本让检查扣减成为一个原子操作就不会再有这个问题。更复杂一点如果要求防重复提交可以带上用户维度的幂等判断用户已秒杀过就不能再扣。这时候在库存key之外再加一个用户记录key脚本里一起处理local has_bought redis.call(GET, KEYS[2]) if has_bought then return 0 end local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock 0 then return 0 end redis.call(DECR, KEYS[1]) redis.call(SET, KEYS[2], 1, EX, ARGV[1]) return 1这样两个key一起操作KEYS[1]是库存keyKEYS[2]是用户已购标记key。脚本保证了查用户是否买过检查库存扣库存标记用户已购这个完整流程的原子性。这里要提醒一句在生产环境里如果库存是放在MySQL里的Redis脚本只是用来做前置校验和扣减最终还是要以数据库事务为准。Redis脚本解决的是并发高时把数据库压垮的问题不是数据库的替代品。3.4 缓存治理让缓存和数据库的数据一致性更稳再说一个我实际项目中处理过的场景缓存治理。缓存和数据库的不一致问题是很多人面试都绕不开的。一种常见策略是更新数据库成功后删除缓存或者更新缓存。但这里有个经典的并发坑两个线程同时更新同一条数据A先更新数据库然后B更新数据库然后B更新缓存最后A更新缓存。这样缓存里就是旧数据了因为A的缓存更新被B覆盖或者反超数据就错了。用Lua脚本可以在一定程度上缓解这个问题。比如我们可以把读取当前缓存值、比较版本号、按版本号决定是否写缓存这个过程原子化。当然这只是缓存治理的一个维度更好的方式通常是结合消息队列、版本号、订阅通知等手段。Redis脚本在缓存治理中还有一个常用场景批量设置缓存。例如一个接口需要同时获取用户的多个信息如果没有脚本就得多条GET命令每条都有网络开销。把它们合并到一个Lua脚本里一次网络请求全部取回来接口延迟能降不少。local result {} for i, key in ipairs(KEYS) do result[i] redis.call(GET, key) end return result注意这里用了ipairs(KEYS)遍历所有传入的key并逐一GET最后把所有值装进table返回。这个脚本一次可以取任意多个key的值。不过这种通用脚本在生产环境慎用。一次取太多key会把内存占用和阻塞时间拉高建议一次控制在几十个以内。4. 脚本的进阶管理缓存、集群与新的形态4.1 SCRIPT LOAD和EVALSHA省流量、防重复解析前面我们一直在用EVAL直接传脚本内容。但每次传完整脚本流量消耗大而且Redis每次都要重新解析脚本对性能不友好。Redis提供了脚本缓存的机制脚本可以预加载到Redis内存中用一个SHA1摘要来标识下次直接用摘要执行。redis-cli SCRIPT LOAD return redis.call(SET, KEYS[1], ARGV[1])会返回一个40位的SHA1字符串比如0e916c1034e6985a1b2d3c4d5e6f7a8b9c0d1e2f然后执行redis-cli EVALSHA 0e916c1034e6985a1b2d3c4d5e6f7a8b9c0d1e2f 1 mykey myvalue效果和EVAL完全一样但不会重复传脚本内容。在编程语言的客户端里比如Java的Lettuce、RedisTemplate或者Python的redis-py底层通常会自动处理SCRIPT LOAD和EVALSHA的切换。你只需要调用对应的API客户端会在第一次执行脚本时自动LOAD之后用SHA1执行如果发现NOSCRIPT错误还会自动回退到EVAL再LOAD一次。这块你不用太操心但心里要明白这个机制排查问题的时候才能跟得上。4.2 脚本缓存的两大坑重启丢失和节点不一致脚本缓存有两个容易被忽视的坑。第一Redis重启后脚本缓存会全部清空。因为脚本缓存是存在内存里的不像数据有持久化机制。所以客户端在Redis重启之后拿着旧的SHA1去执行EVALSHA会收到NOSCRIPT错误。如果你的客户端库没有自动回退机制这里就会出现线上报错。解决方法是客户端要有NOSCRIPT回退逻辑或者业务启动时重新SCRIPT LOAD。第二主从复制和集群环境下脚本缓存的复制机制要留意。Redis在传播脚本执行结果时不会传播脚本内容本身而是传播执行结果或者里面的写命令。从节点不会自动加载主节点上的脚本缓存。所以如果你在主节点上SCRIPT LOAD了一个脚本然后想在从节点上执行EVALSHA从节点可能会报NOSCRIPT。实际生产里读写分离架构要特别注意这个问题。另外脚本也不要加载得太多太大。每个脚本都会挂一份在内存里如果你动不动就塞一个几十KB的脚本而且数量上千个内存和CPU都会被拖累。最好保持脚本短小精悍同一个脚本尽量复用而不是每个请求都动态拼接一段新脚本。4.3 集群模式下脚本的两个硬约束Redis Cluster模式下使用脚本有两个硬性约束你必须记住。第一脚本操作的key必须都在同一个hash slot中。如果你在脚本里同时访问两个不同slot的key会直接报CROSSSLOT错误。解决办法使用Hash Tag。也就是在key里加一对花括号让Redis只拿花括号里的内容去计算hash slot。例如user:{1001}:cart和user:{1001}:profile这两个key计算slot时只看花括号里的1001所以它们会落在同一个slot上就可以在同一个脚本里操作了。第二集群模式下脚本不能使用随机命令比如time()、rand()、spop等在脚本里调用的、返回结果不确定的命令做有约束的写入。RDB持久化和主从复制时如果脚本结果无法被明确重放会有一致性问题。简单说就是不要在脚本里使用srandmember、time这种随机性强的命令去写数据尽量用确定的参数。这个约束在Redis官方文档里称为脚本不产生随机副作用。4.4 Redis FunctionsRedis 7带来的新形态Redis 7.0引入了Redis Functions机制我建议现在新项目可以直接用Functions尤其是复杂逻辑。Functions跟老的SCRIPT LOAD机制相比有几个明显优势函数有名字和库的概念可以组织成模块不像脚本只有一个SHA1。函数在集群模式下是自动在所有节点上管理的不需要每台节点单独LOAD。函数支持长驻内存不随脚本内容变化而动态改变更稳定。不过Functions的API和传统Lua脚本有一些差异网上资料也相对少一点。如果你想走得更深后续可以单独写一篇Functions的文章基础还是得先把Lua脚本搞明白。5. 常见问题与排障实录5.1 NOSCRIPT眼睁睁看着报错的经典场景看错误日志时你一定见过下面这个NOSCRIPT No matching script. Please use EVAL.原因我在前面已经说过脚本缓存里找不到对应的SHA1。触发场景常见有三种Redis服务重启导致缓存清空集群中主从切换后新主节点上没有加载旧脚本客户端在重启之后没有重新LOAD脚本就执行EVALSHA。排查手段执行SCRIPT EXISTS sha1看看有没有返回1。如果返回0说明缓存确实不在了。解决方案客户端在捕获到NOSCRIPT异常后回退到EVAL重新执行并重新LOAD。这也是为什么我建议尽量使用成熟的Redis客户端库它们一般内置了这个回退逻辑。5.2 脚本超时Redis阻塞从何而来Redis是单线程模型这意味着你提交的Lua脚本在运行期间Redis整个实例基本是天塌下来也得等着的。虽然脚本里大部分Redis操作是毫秒级的但如果你在脚本里写了死循环或者特别耗时的计算Redis就会一直卡在那里所有其他客户端请求都会排队。Redis提供了lua-time-limit参数默认是5000毫秒。注意脚本执行达到这个时间后Redis并不会自动中断脚本而是开始响应其他客户端的请求但这些请求只会收到BUSY错误同时脚本还在后台继续执行。除非你执行SCRIPT KILL命令仅当脚本没有写入操作时才能成功否则只能等脚本自己跑完或者被迫重启Redis。我踩过最惨的一次坑是同事在一个Lua脚本里写了一个巨大的循环去遍历一个Hash的所有field然后对每个field做解析和裁剪。测试环境怎么跑都没事生产数据量一上来脚本直接跑了十几秒整个Redis实例被堵死线上接口全部雪崩。所以在这里我用亲身经历提醒你脚本里循环一定要克制数据量大的操作不要塞进脚本能用数据库做的就用数据库做。5.3 command timed out客户端连接栈被脚本堵死了搜索词里有过redis command timed out对应Java生态常见的报错就是Lettuce的RedisCommandTimeoutException: Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这类超时除了网络问题之外最常见的原因就是Redis服务端被慢脚本阻塞。因为脚本执行期间整个Redis是卡住的客户端的命令得不到响应超过客户端配置的timeout自然就报这个错。排查思路先去看Redis的慢日志SLOWLOG GET看看是不是有Lua脚本相关的慢记录。看Redis当前是否还在执行脚本执行INFO clients找出正在执行的lua相关连接。如果经常发生说明某些脚本的复杂度太高需要review脚本里的循环和一次处理的key数量。同时在客户端侧适当调大超时时间但这是治标不治本根治才是关键。5.4 脚本里误用KEYS命令的后果不容小觑这里说的KEYS不是Lua脚本里的KEYS数组而是Redis的KEYS命令。在Lua脚本里面调用redis.call(KEYS, user:*)等于是在Redis主线程里做全库key匹配数据量大时会把Redis打挂。这比在客户端通过redis-cli执行KEYS还要危险因为客户端执行KEYS时至少还有一层网络阻塞而脚本里调用KEYS是Redis自己找自己的茬一点保护缓冲都没有。如果你确实需要在脚本里匹配一批key正确方式是在外部先用SCAN命令拿到匹配的key列表然后把这些key作为参数传给脚本操作。注意这个方式也无法完全保证一致性但总比把Redis打挂强得多。另一个相关经验是脚本里的redis.call(FLUSHALL)这种危险命令生产环境一定要配置好权限绝不能让业务脚本里出现。我见过有公司在排查问题时为了方便直接在脚本里写了个FLUSHALL一执行缓存全部清空数据库紧接着就被打爆了。5.5 关于脚本的持久化和主从复制前面提到脚本回放的问题。Redis的持久化RDB和AOF以及主从复制都需要保证重放脚本时结果一致。为此Redis会采用两种策略对于脚本Redis向从节点或AOF传播的是脚本执行后的写命令而不是直接传播脚本本身。对于写入类命令Redis传播的是精确结果而不是让从节点重新执行一遍脚本。这意味着即使脚本里用了TIME或者RANDOM命令从节点拿到的也是主节点执行后的实际值。正因为有这个机制在Redis Cluster主从切换后新主节点上如果有脚本缓存缺失就会产生串复杂问题。这也是为什么我建议脚本逻辑尽量简单、确定性高不要依赖随机值和时间值不仅仅是性能考虑更是为了数据一致性。写在最后我这几年的真实体会说句实在话Redis脚本是我在项目中用过性价比最高的一个技巧。它不需要引入新的中间件不需要复杂的架构改造只靠一个内嵌Lua解释器就能解决大量并发原子性问题。我的项目从最初简单的set、get缓存到后来引入分布式锁、限流、库存扣减、缓存治理都是用Redis脚本一步步迭代上去的系统稳定性确实肉眼可见地提升了。最后再分享一个小技巧如果你在做Redis脚本方案设计一定养成先写伪代码再翻译成Lua的习惯。先在文档里把流程、边界条件、失败分支理清楚再落到Lua脚本可以避免很多坑。另外生产环境的脚本尽量加上版本号注释比如-- inventory_decr_v1方便以后排查时定位是不是脚本版本不一致带来的问题。我这些年在线上排查的很多诡异问题最后追根溯源都是因为脚本更新了但某个节点缓存没更新。细节决定成败Redis脚本虽然好用但每一个细节都有可能成为生产事故的导火索希望这篇文章能帮你把这些坑都提前避掉。
返回列表