Redis 支持执行 Lua 脚本,这是它最强大的特性之一。借助 Lua 脚本,可以在 Redis 服务端实现原子操作,避免并发问题,同时减少网络往返开销。
今天聊几个常用的 Lua 脚本技巧,不整虚的,直接上能跑的例子。
为什么用 Lua 脚本
先说个场景:库存扣减。
// 普通做法:两次网络往返Stockstock=redisTemplate.opsForValue().get("stock:001");if(stock.getCount()>0){redisTemplate.opsForValue().decrement("stock:001");}问题在哪?get和decrement之间没有原子性,高并发下可能超卖。
用 Lua 脚本,一行搞定:
-- 库存扣减 Lua 脚本localstock=redis.call('GET',KEYS[1])ifstockandtonumber(stock)>0thenreturnredis.call('DECR',KEYS[1])endreturn0Redis 执行 Lua 脚本是单线程的,天然原子,不用担心并发问题。
技巧一:分布式锁
publicclassRedisLock{@AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringLOCK_SCRIPT="if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then "+" redis.call('EXPIRE', KEYS[1], ARGV[2]) "+" return 1 "+"end "+"return 0";publicbooleantryLock(Stringkey,Stringvalue,longexpireSeconds){Longresult=redisTemplate.execute(newDefaultRedisScript<>(LOCK_SCRIPT,Long.class),Collections.singletonList(key),value,String.valueOf(expireSeconds));returnresult!=null&&result==1;}}Lua 脚本保证 SETNX 和 EXPIRE 的原子性,避免锁忘了设过期时间的坑。
技巧二:滑动窗口限流
限流最怕的是边界问题——在窗口边缘一瞬间请求集中通过。用 Lua 脚本实现滑动窗口限流:
-- 滑动窗口限流-- KEYS[1] = 限流key-- ARGV[1] = 窗口大小(毫秒)-- ARGV[2] = 最大请求数localkey=KEYS[1]localwindow=tonumber(ARGV[1])locallimit=tonumber(ARGV[2])localnow=tonumber(ARGV[3])localwindowStart=now-window-- 删除窗口外的旧记录redis.call('ZREMRANGEBYSCORE',key,0,windowStart)-- 统计当前窗口请求数localcount=redis.call('ZCARD',key)ifcount<limitthenredis.call('ZADD',key,now,now..':'..math.random())redis.call('PEXPIRE',key,window)return1endreturn0Java 调用:
publicbooleanisAllowed(Stringkey,longwindowMs,intlimit){DefaultRedisScript<Long>script=newDefaultRedisScript<>();script.setScriptText("local key = KEYS[1] "+"local window = tonumber(ARGV[1]) "+"local limit = tonumber(ARGV[2]) "+"local now = tonumber(ARGV[3]) "+"local windowStart = now - window "+"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) "+"local count = redis.call('ZCARD', key) "+"if count < limit then "+" redis.call('ZADD', key, now, now .. ':' .. math.random()) "+" redis.call('PEXPIRE', key, window) "+" return 1 "+"end "+"return 0");script.setResultType(Long.class);Longresult=redisTemplate.execute(script,Collections.singletonList(key),String.valueOf(windowMs),String.valueOf(limit),String.valueOf(System.currentTimeMillis()));returnresult!=null&&result==1;}用 ZSET 记录每次请求的时间戳,窗口大小用 PEXPIRE 自动过期,简洁又精准。
技巧三:点赞防刷
用 Lua 实现单用户点赞频率限制:
-- 用户点赞频率限制-- KEYS[1] = 点赞频率限制key (user:like:count:{userId})-- ARGV[1] = 限制时间(秒)-- ARGV[2] = 最大点赞次数localkey=KEYS[1]localexpire=tonumber(ARGV[1])locallimit=tonumber(ARGV[2])localcount=redis.call('GET',key)ifcountandtonumber(count)>=limitthenreturn0endifcountthenredis.call('INCR',key)elseredis.call('SET',key,1,'EX',expire)endreturn1结合实际的点赞操作一起原子执行:
-- 完整点赞 Lua 脚本-- KEYS[1] = 点赞频率key-- KEYS[2] = 点赞状态key (article:likes:{articleId})-- ARGV[1] = 限制时间-- ARGV[2] = 最大次数-- ARGV[3] = 用户idlocalfreqKey=KEYS[1]locallikeKey=KEYS[2]localexpire=tonumber(ARGV[1])locallimit=tonumber(ARGV[2])localuserId=ARGV[3]-- 检查频率限制localcount=redis.call('GET',freqKey)ifcountandtonumber(count)>=limitthenreturn{-1,'点赞太频繁'}end-- 检查是否已点赞localalreadyLiked=redis.call('SISMEMBER',likeKey,userId)ifalreadyLikedthenreturn{-2,'已点赞过'}end-- 执行点赞redis.call('SADD',likeKey,userId)ifcountthenredis.call('INCR',freqKey)elseredis.call('SET',freqKey,1,'EX',expire)endlocallikeCount=redis.call('SCARD',likeKey)return{1,likeCount}技巧四:批量扣减库存
-- 批量扣减库存(支持多商品)-- KEYS = 商品库存key列表-- ARGV = 要扣减的数量列表-- 返回:成功返回1,失败返回商品索引localquantities={}fori=1,#ARGVdoquantities[i]=tonumber(ARGV[i])endlocalsuccess=truelocalfailedIndex=-1fori=1,#KEYSdolocalstock=tonumber(redis.call('GET',KEYS[i])or0)ifstock<quantities[i]thensuccess=falsefailedIndex=ibreakendendifsuccessthenfori=1,#KEYSdoredis.call('DECRBY',KEYS[i],quantities[i])endreturn1endreturnfailedIndex技巧五:延迟队列
用 Lua 实现简单的延迟队列:
-- 延迟任务入队-- KEYS[1] = 延迟队列key-- ARGV[1] = 任务数据-- ARGV[2] = 延迟执行时间戳(毫秒)localscore=tonumber(ARGV[2])redis.call('ZADD',KEYS[1],score,ARGV[1])return1-- 取出可执行的延迟任务-- KEYS[1] = 延迟队列key-- ARGV[1] = 当前时间戳(毫秒)-- ARGV[2] = 每次最多取几条localnow=tonumber(ARGV[1])locallimit=tonumber(ARGV[2])localtasks=redis.call('ZRANGEBYSCORE',KEYS[1],0,now,'LIMIT',0,limit)if#tasks>0thenfori=1,#tasksdoredis.call('ZREM',KEYS[1],tasks[i])redis.call('RPUSH',KEYS[1]..':ready',tasks[i])endendreturntasks总结
| 场景 | 核心价值 |
|---|---|
| 分布式锁 | SETNX + EXPIRE 原子操作 |
| 滑动窗口限流 | ZSET 按时间戳排序,天然实现滑动窗口 |
| 点赞防刷 | 多条件判断 + 状态更新一次完成 |
| 批量扣减 | 先检查后操作,原子性保证不超卖 |
| 延迟队列 | ZADD 按时间戳排序,ZRANGEBYSCORE 取到期任务 |
Lua 脚本让 Redis 的能力大幅扩展,核心就是:把多次网络往返变成一次,把多个操作变成原子的。用好了,Redis 不只是个缓存库。