Redis Lua 脚本——把多条命令打包成一个原子操作
没有 Lua 的时候问题长什么样先看一个最简单的场景点赞。// 服务端代码判断用户是否已经点赞没点就加上Stringkeyuser:like:10001;Stringmembernote:888;LongexistsredisTemplate.opsForSet().isMember(key,member)?1L:null;if(existsnull){redisTemplate.opsForSet().add(key,member);// 这两行之间可能被其他请求插入}问题在哪isMember返回false后、add执行之前另一个线程也判断了同一个 key也是false于是也add——同样的操作执行了两次。请求BRedis请求A请求BRedis请求A添加了两次SISMEMBER user:like:10001 note:8880不存在SISMEMBER user:like:10001 note:8880也不存在SADD user:like:10001 note:888SADD user:like:10001 note:888这叫check-then-act 竞态条件。Java 代码帮不上忙——两条 Redis 命令之间Redis 不负责加锁。Lua 怎么解决的Redis 执行 Lua 脚本的规则很简单整个脚本作为一个原子操作执行。脚本运行期间Redis 不执行任何其他命令。所以把判断 执行写成一段 Lua丢给 Redis——Redis 一口气跑完全程无打扰。-- 判断 执行的完整逻辑在 Redis 内部一气呵成没人插得进来ifredis.call(EXISTS,KEYS[1])0thenreturn-1-- 集合还不存在endifredis.call(ZSCORE,KEYS[1],ARGV[1])thenreturn-2-- 已经点赞过了endredis.call(ZADD,KEYS[1],ARGV[2],ARGV[1])return0-- 点赞成功六行 Lua解决了 Java 端需要分布式锁才能保证的原子性。跟 Java 代码的对比就很直观了Java 方式EXISTS → 网络往返1 → ZCARD → 网络往返2 → ZSCORE → 网络往返3 → ZADD → 网络往返4 4 次网络 IO中间可能被其他请求插入。 Lua 方式 1 次网络 IORedis 内部一口气跑完 4 条命令中间无打断。注意Redis 的原子性只保证单条命令或 Lua 脚本内部不被打断。如果你的 Lua 脚本需要操作多个不相关的 keyRedis Cluster 模式下要注意所有 key 必须在同一个 slot否则脚本执行会报错。单机或 Sentinel 模式没这个限制。Spring Boot 里怎么写——两种方式方式一外部 .lua 文件 DefaultRedisScript把脚本放在resources/lua/目录下Java 里加载执行// 读取 .lua 文件DefaultRedisScriptLongscriptnewDefaultRedisScript();script.setScriptSource(newResourceScriptSource(newClassPathResource(/lua/follow_check_and_add.lua)));script.setResultType(Long.class);// KEYS 一个 keyARGV 两个参数ListStringkeysCollections.singletonList(user:following:10001);LongresultredisTemplate.execute(script,keys,20002,String.valueOf(System.currentTimeMillis()));-- resources/lua/follow_check_and_add.lualocalkeyKEYS[1]-- user:following:10001localfollowUserIdARGV[1]-- 20002localtimestampARGV[2]-- 时间戳-- 校验集合是否存在ifredis.call(EXISTS,key)0thenreturn-1-- 集合不存在end-- 校验关注人数是否达到上限localsizeredis.call(ZCARD,key)ifsize1000thenreturn-2-- 关注已达上限end-- 校验是否已经关注ifredis.call(ZSCORE,key,followUserId)thenreturn-3-- 已经关注过了endredis.call(ZADD,key,timestamp,followUserId)return0-- 关注成功把 Lua 返回值定义为枚举类把 Lua 返回值和业务含义映射起来GetterAllArgsConstructorpublicenumFollowLuaResultEnum{SUCCESS(0,关注成功),NOT_EXIST(-1,关注列表不存在),UPPER_LIMIT(-2,关注已达上限1000人),ALREADY_FOLLOWED(-3,已经关注过了);privatefinalintcode;privatefinalStringdescription;publicstaticFollowLuaResultEnumvalueOf(longcode){for(FollowLuaResultEnume:values()){if(e.getCode()code){returne;}}thrownewIllegalArgumentException(未知的 Lua 返回码: code);}}Java 端用枚举映射返回值LongrawResultredisTemplate.execute(script,keys,followUserId,timestamp);FollowLuaResultEnumresultFollowLuaResultEnum.valueOf(rawResult);switch(result){caseSUCCESS-log.info(关注成功);caseNOT_EXIST-thrownewBizException(关注列表不存在);caseUPPER_LIMIT-thrownewBizException(关注已达上限1000人);caseALREADY_FOLLOWED-thrownewBizException(已经关注过了);}方式二字符串内联 RedisScript.of()简单的脚本直接在 Java 里写不用单独建 .lua 文件// 一行 Lua 内联布隆过滤器添加 设置过期RedisScriptLongbloomAddScriptRedisScript.of(return redis.call(BF.ADD, KEYS[1], ARGV[1]),Long.class);redisTemplate.execute(bloomAddScript,Collections.singletonList(bloomKey),noteCreatorId);什么时候用哪种方式适用场景DefaultRedisScript .lua 文件多行逻辑、多返回值、需要版本管理、IDE 语法高亮RedisScript.of()内联一两行简单操作懒得建文件KEYS 和 ARGV 的区别redisTemplate.execute(script,keys,arg1,arg2,...);// ↑ KEYS ↑ ARGVKEYSARGV在 Lua 里怎么取KEYS[1]、KEYS[2]……ARGV[1]、ARGV[2]……传的是Redis key普通参数value、时间戳、过期秒数……为什么区分Redis Cluster 模式下框架需要根据 KEYS 做哈希路由决定脚本在哪个节点执行。ARGV 只传值不参与路由所以 keys 传 Redis keyargv 传其他参数// ✅ 正确key 放 KEYS值放 ARGVredisTemplate.execute(script,Collections.singletonList(user:like:10001),// KEYSnote:888,String.valueOf(System.currentTimeMillis()));// ARGV// ❌ 错误key 放 ARGV——单机没问题Cluster 模式下路由出错redisTemplate.execute(script,Collections.emptyList(),user:like:10001,note:888);什么时候该用 Lua什么时候不该用该用多条 Redis 命令之间有逻辑依赖check-then-act操作同一个 key 需要先判断再执行比如判上限再 ZADD、判重复再 SADD两个 key 之间需要同步操作比如取关同时删关注表和粉丝表不该用单条命令本来就是原子的不需要包一层 Lua脚本太复杂——Redis 执行 Lua 期间是单线程的上百行的脚本会长时间阻塞其他请求脚本里想调外部服务——Redis Lua 不能发 HTTP、不能查 DBLua 脚本怎么写——基础语法够用了Redis Lua 只用 Lua 5.1 的一个子集。写 Redis 脚本不需要学完整 Lua下面这些就够日常用了。变量和注释-- 单行注释用两个减号localnamehello-- local 声明局部变量没有 local 就是全局变量别用全局变量localcount0countcount1-- 赋值条件判断ifredis.call(EXISTS,KEYS[1])0thenreturn-1-- return 直接结束脚本返回值给 Java 端end-- 多分支localsizeredis.call(ZCARD,KEYS[1])ifsize1000thenreturn-2elseifsize10thenreturn-3elsereturn0endRedis 命令怎么调——redis.call()-- 格式redis.call(命令名, 参数1, 参数2, ...)-- 跟你在 redis-cli 里敲的命令一一对应redis.call(SET,key1,value1)-- SET key1 value1redis.call(EXPIRE,key1,3600)-- EXPIRE key1 3600localvalredis.call(GET,key1)-- GET key1返回值可以存到变量localexistsredis.call(EXISTS,KEYS[1])-- EXISTS keylocalsizeredis.call(ZCARD,KEYS[1])-- ZCARD keyredis.call(ZADD,KEYS[1],ARGV[2],ARGV[1])-- ZADD key score memberredis.call(ZREM,KEYS[1],ARGV[1])-- ZREM key memberlocalscoreredis.call(ZSCORE,KEYS[1],ARGV[1])-- ZSCORE key member就是把你熟悉的 Redis 命令搬进 Lua中间没啥区别。取传进来的参数——KEYS 和 ARGV-- 数组下标从 1 开始不是 0localmyKeyKEYS[1]-- 第一个 keylocalmyValARGV[1]-- 第一个参数localmyVal2ARGV[2]-- 第二个参数循环-- for 循环fori1,10doredis.call(SET,key..i,i)end-- 遍历数组localmembersredis.call(SMEMBERS,KEYS[1])fori,memberinipairs(members)doredis.call(DEL,member)end返回值-- 返回单个值return0returnredis.call(EXISTS,KEYS[1])-- 直接把 Redis 命令的返回值返回-- 返回数组Java 端用 ListLong 接收localresult{}result[1]100result[2]200returnresult写 Lua 脚本的几个习惯所有变量用local——不加local就是全局变量多个脚本之间可能互相覆盖脚本保持短小——控制在几十行内。长逻辑放 Java 里处理Lua 里只放需要原子性的那几行用不同的返回值区分不同的失败原因——别所有异常都return -1像上面关注脚本用 -1/-2/-3 各自对应不同的情况数组下标从 1 开始——Lua 的习惯KEYS[1]是第一个元素KEYS[0]是 nil测试的时候直接用redis-cli --eval——不用重启 Javaredis-cli --eval script.lua key1 key2 , arg1 arg2直接跑