Lua 脚本可以确保在 Redis 中原子地存储 Hash 数据,并同时设置 Key 的过期时间,防止 Redis 在两步操作之间发生宕机,从而导致缓存数据未能正确设置过期时间的问题。
1. 传统方式的问题
假设我们直接用 Java 代码执行 HMSET 和 EXPIREAT:
stringRedisTemplate.opsForHash().putAll(couponTemplateCacheKey, actualCacheTargetMap);
stringRedisTemplate.expireAt(couponTemplateCacheKey, couponTemplateDO.getValidEndTime());
这可能会出现极端情况:
- HMSET 执行完,数据成功存入 Redis。
- 但在执行 EXPIREAT 之前,Redis 宕机了,导致这个 Key 不会过期,形成脏数据(数据永远留在 Redis)。
2. 解决方案:使用 Lua 脚本
Lua 脚本在 Redis 中是原子执行的,Redis 不会在执行脚本的过程中被其他命令打断,所以可以保证 HMSET 和 EXPIREAT 要么 全部成功,要么 全部失败,从而避免宕机导致数据永久存储的问题。
Lua 脚本
redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1))
redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])
解释
- redis.call(‘HMSET’, KEYS[1], unpack(ARGV, 1, #ARGV – 1))
- HMSET 用于存储 Hash 数据。
- KEYS[1] 是 Redis 的 Key(这里是 couponTemplateCacheKey)。
- unpack(ARGV, 1, #ARGV – 1) 代表 ARGV 中的前 N-1 个参数,用于 HMSET。
- redis.call(‘EXPIREAT’, KEYS[1], ARGV[#ARGV])
- EXPIREAT 设置 Key 的过期时间,确保 Key 在指定的 Unix 时间戳自动删除。
- ARGV[#ARGV] 获取 ARGV 的最后一个参数,即过期时间(Unix 时间戳,秒级别)。
3. Java 代码解析
3.1 定义 Lua 脚本
String luaScript = "redis.call('HMSET', KEYS[1], unpack(ARGV, 1, #ARGV - 1)) " +
"redis.call('EXPIREAT', KEYS[1], ARGV[#ARGV])";
这段 Lua 代码会:
- 一步完成存储数据(HMSET)。
- 紧接着设置过期时间(EXPIREAT),确保数据不会意外永久存留。
3.2 准备 Redis Key
List<String> keys = Collections.singletonList(couponTemplateCacheKey);
- keys 代表 Redis 的 Key(即 couponTemplateCacheKey)。
3.3 构造 Lua 脚本的参数
List<String> args = new ArrayList<>(actualCacheTargetMap.size() * 2 + 1);
actualCacheTargetMap.forEach((key, value) -> {
args.add(key);
args.add(value);
});
- actualCacheTargetMap 是需要存入 Redis 的 Hash 数据(多个字段和值)。
- 这里把 actualCacheTargetMap 转换为 一维数组形式,用于传入 Lua 的 ARGV。
3.4 添加 Key 的过期时间
args.add(String.valueOf(couponTemplateDO.getValidEndTime().getTime() / 1000));
- couponTemplateDO.getValidEndTime().getTime() 以 毫秒级 返回时间。
- / 1000 转换为 秒级 Unix 时间戳,符合 EXPIREAT 的要求。
3.5 执行 Lua 脚本
stringRedisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
keys,
args.toArray()
);
- DefaultRedisScript<>(luaScript, Long.class):创建一个 Redis 脚本对象,返回值是 Long 类型。
- keys:传入 Redis Key。
- args.toArray():传入 Hash 字段和值 + 过期时间 作为 ARGV 参数。
4. 总结
| 方案 | 可能问题 | 是否原子执行 |
|---|---|---|
| 普通 Java 代码 | HMSET 执行后,EXPIREAT 可能因 Redis 宕机未执行,导致缓存数据不会自动过期 | ❌ 不是原子操作 |
| Lua 脚本 | HMSET 和 EXPIREAT 在 Redis 内部原子执行,不会被中断,保证 Key 一定有过期时间 | ✅ 100% 原子操作 |
5. 适用场景
✅ 分布式缓存:防止缓存数据因为 Redis 宕机而永久存储。
✅ 秒杀 / 限流:保证 Redis 的限流数据一定会自动过期。
✅ 高并发场景:减少 Redis 网络开销,提高执行效率。
在 Redis 需要同时存储数据并保证自动过期 的情况下,Lua 脚本是最安全的方案!
✅ 在Java 定义 Lua 脚本,可以是xxx.lua文件,也可是写成字符串,并将它发送给 Redis 服务器,真正的 是 在 Redis 服务器内部执行的。
这样做的好处是:
- 减少 Java 与 Redis 之间的网络通信(一次请求执行多个命令)。
- 保证多个 Redis 命令的原子性,避免并发问题。
- 提升性能,避免多次请求带来的开销。
- Java 代码只是把 Lua 脚本作为字符串传递给 Redis String luaScript = “redis.call(‘SET’, KEYS[1], ARGV[1])”; 这里 redis.call 还没有执行,只是一个字符串。
- Java 通过 Redis 客户端(如 Spring Data Redis)把这个 Lua 脚本发送给 Redis stringRedisTemplate.execute( new DefaultRedisScript<>(luaScript, String.class), Collections.singletonList(“name”), “huangkeqin” ); 这里 Java 只是把 Lua 代码交给 Redis 服务器,执行由 Redis 负责。
- Redis 服务器接收到 Lua 代码后,在内部执行 redis.call redis.call(‘SET’, ‘name’, ‘huangkeqin’) 这个命令是在 Redis 服务器内部执行的,不会经过 Java 端的处理,也不会被其他命令打断。
🚀 Java + Lua + Redis = 高性能方案
- Java 只是一个发送 Lua 脚本的客户端,不执行 Redis 命令。
- Redis 真正执行 Lua 脚本,并在服务器内部完成所有操作。
- redis.call 必须在 Redis 服务器内部执行,它的作用是执行 Redis 命令,并返回结果给 Java。
暂时没有回复