Redis事务与Lua脚本

Redis事务与Lua脚本

Spring 事务用起来简单,但失效场景非常多。很多人遇到过 @Transactional 不生效的情况,却不知道原因。本文把日常开发中常见的坑和对应的排查思路整理出来,帮你避免踩坑。

一、Redis事务

#

1.1 事务基础

Redis事务通过MULTI、EXEC、WATCH等命令实现:

MULTI           # 开启事务
SET key1 value1
SET key2 value2
INCR counter
EXEC # 执行事务

#

1.2 事务命令

命令 作用
MULTI 开启事务,后续命令进入队列
EXEC 执行事务队列中的所有命令
DISCARD 取消事务,清空队列
WATCH 监视一个或多个key,如果被修改则事务失败
UNWATCH 取消对所有key的监视

#

1.3 事务示例

# 无WATCH的简单事务
MULTI
SET balance:1001 1000
SET balance:1002 2000
EXEC

# 带WATCH的乐观锁事务
WATCH balance:1001 balance:1002
MULTI
DECRBY balance:1001 100
INCRBY balance:1002 100
EXEC
# 如果执行期间balance:1001或balance:1002被修改,EXEC返回nil

#

1.4 Java中使用事务

@Service
public class TransactionService {

@Autowired
private StringRedisTemplate redis;

public void transfer(String from, String to, int amount) {
redis.execute(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// WATCH监控key
operations.watch(Arrays.asList(from, to));

// 获取余额
int fromBalance = Integer.parseInt((String) operations.opsForValue().get(from));

if (fromBalance < amount) {
operations.unwatch();
throw new RuntimeException("余额不足");
}

// 开启事务
operations.multi();
operations.opsForValue().decrement(from, amount);
operations.opsForValue().increment(to, amount);

// 执行事务
List<Object> results = operations.exec();

// 如果results为null,说明WATCH的key被修改,事务失败
if (results == null) {
throw new RuntimeException("转账失败,请重试");
}

return results;
}
});
}
}

#

1.5 Redis事务的特点

优点

  • 命令批量执行,减少网络往返
  • WATCH提供乐观锁机制

局限性

  1. 不支持回滚:如果事务中某条命令执行失败,其他命令仍会执行
  2. 没有隔离级别概念:事务中的命令不会阻塞其他客户端
  3. 不支持条件判断:不能根据查询结果决定后续操作
# 事务中命令失败的例子
MULTI
SET key1 value1
LPUSH key1 value2 # 错误!key1是String,不能LPUSH
SET key2 value2
EXEC

# 结果:
# key1 = value1(SET成功)
# LPUSH报错但继续执行
# key2 = value2(SET成功)

二、Lua脚本

#

2.1 为什么需要Lua

Redis事务的局限性促使我们使用Lua脚本:

  • Lua脚本可以包含逻辑判断
  • Lua脚本在Redis中原子执行
  • 可以减少网络往返

#

2.2 执行Lua脚本

# 方式一:直接执行
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 mykey myvalue

# 方式二:先加载再执行(推荐,减少带宽)
SCRIPT LOAD "return redis.call('set', KEYS[1], ARGV[1])"
# 返回SHA1: a5b0d4e1...
EVALSHA a5b0d4e1... 1 mykey myvalue

#

2.3 Lua脚本基础语法

-- 调用Redis命令
redis.call('set', 'key', 'value')

-- 获取命令结果
local value = redis.call('get', 'key')

-- 条件判断
local balance = redis.call('get', 'balance')
if tonumber(balance) >= 100 then
redis.call('decrby', 'balance', 100)
return 1
else
return 0
end

-- 循环
for i = 1, 10 do
redis.call('lpush', 'list', i)
end

#

2.4 Lua脚本实现原子操作

# 安全扣减库存
EVAL "
local stock = redis.call('get', KEYS[1])
if stock == false then
return -1 -- 库存不存在
end
if tonumber(stock) <= 0 then
return 0 -- 库存不足
end
redis.call('decr', KEYS[1])
return 1 -- 扣减成功
" 1 stock:1001

#

2.5 Java中使用Lua脚本

@Service
public class LuaScriptService {

@Autowired
private StringRedisTemplate redis;

// 扣减库存脚本
private static final String DECREASE_STOCK_SCRIPT =
"local stock = redis.call('get', KEYS[1]) " +
"if stock == false then return -1 end " +
"if tonumber(stock) <= 0 then return 0 end " +
"redis.call('decr', KEYS[1]) " +
"return 1";

private DefaultRedisScript<Long> decreaseStockScript;

@PostConstruct
public void init() {
decreaseStockScript = new DefaultRedisScript<>();
decreaseStockScript.setScriptText(DECREASE_STOCK_SCRIPT);
decreaseStockScript.setResultType(Long.class);
}

public boolean decreaseStock(Long productId) {
String key = "stock:" + productId;
Long result = redis.execute(decreaseStockScript, Collections.singletonList(key));

if (result == null || result == -1) {
throw new RuntimeException("库存不存在");
}
if (result == 0) {
return false; // 库存不足
}
return true; // 扣减成功
}
}

#

2.6 复杂Lua脚本示例

实现可重入分布式锁

-- 加锁脚本
local key = KEYS[1]
local threadId = ARGV[1]
local expireTime = tonumber(ARGV[2])

-- 获取锁的当前值
local currentValue = redis.call('hget', key, threadId)

if currentValue == false then
-- 锁不存在或不是当前线程的,尝试获取
local acquired = redis.call('hsetnx', key, threadId, 1)
if acquired == 1 then
redis.call('pexpire', key, expireTime)
return 1
else
return 0
end
else
-- 锁存在且是当前线程的,重入计数+1
redis.call('hincrby', key, threadId, 1)
redis.call('pexpire', key, expireTime)
return 1
end
@Service
public class ReentrantLockWithLua {

@Autowired
private StringRedisTemplate redis;

private static final String LOCK_SCRIPT =
"local currentValue = redis.call('hget', KEYS[1], ARGV[1]) " +
"if currentValue == false then " +
" local acquired = redis.call('hsetnx', KEYS[1], ARGV[1], 1) " +
" if acquired == 1 then " +
" redis.call('pexpire', KEYS[1], ARGV[2]) " +
" return 1 " +
" else " +
" return 0 " +
" end " +
"else " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('pexpire', KEYS[1], ARGV[2]) " +
" return 1 " +
"end";

private static final String UNLOCK_SCRIPT =
"local currentValue = redis.call('hget', KEYS[1], ARGV[1]) " +
"if currentValue == false then return 0 end " +
"if tonumber(currentValue) > 1 then " +
" redis.call('hincrby', KEYS[1], ARGV[1], -1) " +
" return 1 " +
"else " +
" redis.call('del', KEYS[1]) " +
" return 1 " +
"end";

public boolean lock(String key, String threadId, long expireMs) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);
Long result = redis.execute(script, Collections.singletonList(key), threadId, String.valueOf(expireMs));
return result != null && result == 1;
}

public boolean unlock(String key, String threadId) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redis.execute(script, Collections.singletonList(key), threadId);
return result != null && result == 1;
}
}

三、事务 vs Lua脚本对比

特性 事务 Lua脚本
原子性 命令批量执行 脚本整体原子执行
逻辑判断 不支持 支持
回滚 不支持 不支持(但可通过逻辑避免)
网络往返 减少(批量) 极少(一次发送)
复杂度 中高
可维护性 较差(脚本管理)
适用场景 简单批量操作 需要逻辑判断的原子操作

四、实际应用场景

#

4.1 秒杀系统

-- 秒杀扣减脚本
local stockKey = KEYS[1]
local userKey = KEYS[2]
local userId = ARGV[1]

-- 检查用户是否已购买
local purchased = redis.call('sismember', userKey, userId)
if purchased == 1 then
return -1 -- 已购买
end

-- 检查库存
local stock = redis.call('get', stockKey)
if not stock or tonumber(stock) <= 0 then
return 0 -- 库存不足
end

-- 扣减库存并记录用户
redis.call('decr', stockKey)
redis.call('sadd', userKey, userId)

return 1 -- 成功

#

4.2 滑动窗口限流

-- 滑动窗口限流脚本
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 移除窗口外的记录
redis.call('zremrangebyscore', key, 0, now - window)

-- 获取当前窗口内的请求数
local count = redis.call('zcard', key)
if count >= limit then
return 0 -- 限流
end

-- 记录本次请求
redis.call('zadd', key, now, now .. ':' .. math.random())
redis.call('expire', key, math.ceil(window / 1000))

return 1 -- 通过

#

4.3 排行榜更新

-- 原子更新排行榜并获取排名
local key = KEYS[1]
local member = ARGV[1]
local score = tonumber(ARGV[2])

-- 更新分数
redis.call('zadd', key, score, member)

-- 获取排名
local rank = redis.call('zrevrank', key, member)

return rank

五、Lua脚本最佳实践

#

5.1 脚本管理

@Configuration
public class LuaScriptConfig {

@Bean
public Map<String, RedisScript<?>> luaScripts() {
Map<String, RedisScript<?>> scripts = new HashMap<>();

scripts.put("decreaseStock",
new DefaultRedisScript<>(loadScript("lua/decrease_stock.lua"), Long.class));

scripts.put("rateLimit",
new DefaultRedisScript<>(loadScript("lua/rate_limit.lua"), Long.class));

return scripts;
}

private String loadScript(String path) {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("加载Lua脚本失败: " + path, e);
}
}
}

#

5.2 脚本调试

# 使用redis-cli调试Lua脚本
redis-cli --eval script.lua key1 key2 , arg1 arg2

# 注意:逗号分隔KEYS和ARGV

#

5.3 性能注意

Lua脚本执行时:
1. Redis会阻塞其他命令执行(单线程)
2. 脚本执行时间应尽可能短
3. 避免在脚本中执行复杂循环
4. 大数据量操作使用SCAN替代直接遍历

六、总结

场景 推荐方案
简单批量写入 事务(MULTI/EXEC)
需要条件判断 Lua脚本
库存扣减 Lua脚本
分布式锁 Lua脚本
限流计数 Lua脚本
简单转账 事务+WATCH

选择建议:

  1. 简单批量操作:使用事务
  2. 需要逻辑判断:使用Lua脚本
  3. 性能敏感:使用Lua脚本(减少网络往返)
  4. 复杂业务:考虑在应用层实现,Redis只做存储

核心要点

  1. 事务失效的常见原因:非 public 方法、自调用、异常被吞掉、错误的传播级别

  2. 事务传播级别决定了方法之间的事务关系,REQUIRED 是默认值

  3. 使用 @Transactional(rollbackFor = Exception.class) 确保异常回滚

  4. 编程式事务在某些场景下比声明式事务更灵活

总结

事务管理是保证数据一致性的关键。理解事务的工作机制和常见陷阱,能帮你写出更健壮的代码。在实际项目中,合理配置事务边界非常重要。


   转载规则


《Redis事务与Lua脚本》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录