为什么需要Lua脚本?
问题:多个Redis命令无法保证原子性
// ❌ 非原子操作(有并发问题)
Long stock = redis.get("stock");
if (stock > 0) {
redis.decr("stock"); // 可能有多个线程同时执行
return "success";
}
Lua脚本优势:
- ✅ 原子性:脚本执行期间不会插入其他命令
- ✅ 减少网络往返:多个命令一次发送
- ✅ 复用:脚本可以缓存在服务器
基础语法
执行脚本
# EVAL命令
redis> EVAL "return redis.call('SET', 'key', 'value')" 0
OK
# 格式:EVAL script numkeys key [key ...] arg [arg ...]
# numkeys: key的数量
# KEYS[1], KEYS[2]: 传入的key
# ARGV[1], ARGV[2]: 传入的参数
调用Redis命令
-- redis.call():命令错误会报错
redis.call('SET', 'key', 'value')
-- redis.pcall():命令错误会返回错误对象
redis.pcall('SET', 'key', 'value')
实战案例
案例1:库存扣减(秒杀场景)
-- stock_deduct.lua
local key = KEYS[1]
local quantity = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', key) or '0')
if stock >= quantity then
redis.call('DECRBY', key, quantity)
return 1 -- 成功
else
return 0 -- 库存不足
end
Java调用:
@Service
public class StockService {
@Autowired
private RedisTemplate<String, Object> redis;
private static final String SCRIPT =
"local stock = tonumber(redis.call('GET', KEYS[1]) or '0') " +
"if stock >= tonumber(ARGV[1]) then " +
" redis.call('DECRBY', KEYS[1], ARGV[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
public boolean deductStock(String productId, int quantity) {
Long result = redis.execute(
RedisScript.of(SCRIPT, Long.class),
Collections.singletonList("stock:" + productId),
String.valueOf(quantity)
);
return result != null && result == 1;
}
}
案例2:限流(固定窗口)
-- rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current < limit then
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, window)
end
return 1 -- 允许
else
return 0 -- 限流
end
Java实现:
public boolean checkRateLimit(String userId, int limit, int windowSeconds) {
String key = "rate_limit:" + userId;
String script =
"local current = tonumber(redis.call('GET', KEYS[1]) or '0') " +
"if current < tonumber(ARGV[1]) then " +
" redis.call('INCR', KEYS[1]) " +
" if current == 0 then " +
" redis.call('EXPIRE', KEYS[1], ARGV[2]) " +
" end " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long result = redis.execute(
RedisScript.of(script, Long.class),
Collections.singletonList(key),
String.valueOf(limit),
String.valueOf(windowSeconds)
);
return result != null && result == 1;
}
案例3:分布式锁释放
-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
Java实现:
public void unlock(String lockKey, String requestId) {
String script =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redis.execute(
RedisScript.of(script, Long.class),
Collections.singletonList(lockKey),
requestId
);
}
案例4:滑动窗口限流
-- sliding_window_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = 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
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
else
return 0
end
脚本管理
SCRIPT LOAD/EVALSHA
# 1. 加载脚本,返回SHA1
redis> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"6b1bf486c81ceb7edf3c093f4c48582e38c0e791"
# 2. 使用SHA1执行(避免重复传输脚本)
redis> EVALSHA 6b1bf486c81ceb7edf3c093f4c48582e38c0e791 1 mykey
Java实现:
@Component
public class LuaScriptManager {
@Autowired
private RedisTemplate<String, Object> redis;
private String scriptSha;
@PostConstruct
public void init() {
String script = "return redis.call('GET', KEYS[1])";
scriptSha = redis.execute((RedisCallback<String>) connection -> {
return connection.scriptLoad(script.getBytes());
});
}
public String getValue(String key) {
return redis.execute(
RedisScript.of(scriptSha, String.class),
Collections.singletonList(key)
);
}
}
查看已加载脚本
# 查看所有脚本
redis> SCRIPT EXISTS sha1 [sha1 ...]
# 清除所有脚本
redis> SCRIPT FLUSH
# 终止正在执行的脚本
redis> SCRIPT KILL
最佳实践
1. 传递参数使用KEYS和ARGV
-- ✅ 好:通过参数传递
local key = KEYS[1]
local value = ARGV[1]
-- ❌ 不好:硬编码
local key = "mykey"
2. 避免长时间运行
-- ❌ 不好:死循环
while true do
-- ...
end
-- ✅ 好:限制循环次数
for i = 1, 1000 do
-- ...
end
3. 使用局部变量
-- ✅ 好
local result = redis.call('GET', 'key')
-- ❌ 不好:全局变量(可能污染)
result = redis.call('GET', 'key')
4. 错误处理
try {
redis.execute(script, keys, args);
} catch (RedisException e) {
log.error("Lua脚本执行失败", e);
// 降级处理
}
调试技巧
1. 返回中间结果
-- 调试时返回详细信息
local stock = redis.call('GET', KEYS[1])
local quantity = ARGV[1]
return {stock, quantity, "debug info"}
2. 使用redis-cli测试
# 本地测试
redis-cli --eval script.lua key1 key2 , arg1 arg2
# 示例
redis-cli --eval stock_deduct.lua stock:1001 , 10
3. 日志输出
-- Redis 7.0+ 支持
redis.log(redis.LOG_WARNING, "debug message")
性能优化
1. 脚本缓存
// 启动时加载脚本
@PostConstruct
public void loadScripts() {
stockDeductSha = redis.execute(
(RedisCallback<String>) connection ->
connection.scriptLoad(STOCK_DEDUCT_SCRIPT.getBytes())
);
}
// 使用时直接EVALSHA
public boolean deductStock(String productId, int quantity) {
return redis.execute(
RedisScript.of(stockDeductSha, Long.class),
Collections.singletonList("stock:" + productId),
String.valueOf(quantity)
) == 1;
}
2. 减少网络往返
// ❌ 不好:多次调用Redis
Long stock = redis.opsForValue().get("stock");
if (stock > 0) {
redis.opsForValue().decrement("stock");
redis.opsForValue().increment("sales");
}
// ✅ 好:一次Lua脚本完成
String script =
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if stock > 0 then " +
" redis.call('DECR', KEYS[1]) " +
" redis.call('INCR', KEYS[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
限制与注意事项
- 脚本执行时间:超过5秒会被SCRIPT KILL中断
- 原子性:脚本执行期间会阻塞其他命令
- 内存:避免在脚本中创建大量临时数据
- 随机性:避免使用随机数(影响主从一致性)
总结
核心价值:
- 原子性:多个操作作为一个整体
- 性能:减少网络往返
- 灵活:实现复杂业务逻辑
典型场景:
- 库存扣减、秒杀
- 限流算法
- 分布式锁
- 复杂计数统计
最佳实践:
- 参数化脚本(KEYS/ARGV)
- 脚本缓存(EVALSHA)
- 控制执行时间
- 错误处理和降级