Redis 的 Lua 脚本功能是 Java 开发者解决分布式原子操作的强大工具。通过在 Redis 服务端执行 Lua 脚本,我们可以实现复杂的原子逻辑,大幅提升系统性能和数据一致性。
1. 高并发库存系统的挑战
想象一个电商场景:你负责开发库存管理系统,需要确保在数万用户同时抢购时,每件商品都不会超卖。这个看似简单的需求在分布式环境中却面临巨大挑战。
一个典型的库存扣减逻辑包含三个步骤:
- 检查商品库存是否充足
- 如果充足,扣减库存
- 返回操作结果
如果这三步不是原子操作,在高并发下就会出现严重问题。
2. 传统多命令实现及其缺陷
看看我们最初可能的实现方式:
public boolean deductStock(String productId, int amount) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
// 1. 检查库存
String stockStr = jedis.get("product:" + productId + ":stock");
if (stockStr == null) {
return false;
}
int stock = Integer.parseInt(stockStr);
// 2. 判断库存是否足够
if (stock < amount) {
return false;
}
// 3. 扣减库存
jedis.decrBy("product:" + productId + ":stock", amount);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
if (jedis != null) {
jedis.close();
}
}
}
这段代码在单用户情况下工作正常,但在高并发下问题很明显:
假设某商品库存为 10,两个用户同时要买 8 件:
- 用户 A 和用户 B 同时读到库存为 10
- 用户 A 和用户 B 都判断库存足够
- 用户 A 扣减 8 件,库存变为 2
- 用户 B 再扣减 8 件,库存变为-6
结果是超卖了 4 件不存在的商品!
3. Redis 事务与 Lua 脚本对比
为什么不用 Redis 事务解决?下面对比一下 Redis 事务与 Lua 脚本:
特性 | Redis 事务 | Lua 脚本 |
---|---|---|
条件判断 | 不支持(只能执行预定命令) | 支持完整的 if/else/for/while 逻辑 |
原子性 | 命令队列一次执行 | 完全原子性执行 |
网络开销 | 多次网络往返 | 一次网络往返 |
执行性能 | 较慢(多次 IO) | 更快(一次 IO) |
错误处理 | 部分命令可能失败 | 要么全部成功,要么全部失败 |
Redis 事务就像给厨师一张纸条列出做菜步骤:"放油、放肉、加盐、翻炒",但厨师不会检查食材够不够,即使没有肉也会机械地执行"放肉"这一步;而 Lua 脚本就像给厨师一份完整的食谱:"先检查冰箱里有什么食材,如果有肉就做红烧肉,如果只有蔬菜就做素炒,如果什么都没有就叫外卖",厨师可以根据实际情况灵活决定做什么菜。
4. Redis Lua 脚本工作原理
当我们发送一个 Lua 脚本到 Redis 时,发生了什么?下图展示了完整流程:
Redis 会将 Lua 脚本一次性完整执行,期间不会执行其他客户端命令,确保了操作的原子性。这就像数据库中的事务,但比数据库事务更轻量更快速。
5. 使用 Lua 脚本解决库存问题
看看我们如何用 Lua 脚本实现库存扣减:
-- 库存扣减Lua脚本
local key = KEYS[1] -- 库存key
-- 参数校验
if key == nil or key == '' then
return -1 -- 非法Key,返回错误码
end
local amount = tonumber(ARGV[1]) -- 扣减数量
-- 参数校验
if amount == nil or amount <= 0 then
return -2 -- 非法扣减数量
end
-- 获取当前库存
local stock = tonumber(redis.call('get', key) or 0)
-- 判断库存是否足够
if stock < amount then
return 0 -- 库存不足,返回0
else
-- 扣减库存并返回新值(便于业务验证)
redis.call('decrby', key, amount)
local newStock = redis.call('get', key)
return {1, tonumber(newStock)} -- 返回成功标志和新库存
end
这个脚本有什么优势?用一个生活例子解释:
传统方式就像去银行取钱,需要先问柜员余额多少,然后思考一下要取多少,最后告诉柜员取款金额,来回说三次话;
而 Lua 脚本方式就像递给柜员一张纸条:"如果我账户有 1000 元,请取出 500 元,否则不取",一次交流就完成了所有操作。
图示对比:
6. Java 实现最佳实践
6.1 基础实现(使用 try-with-resources)
/**
* 使用Lua脚本实现原子化库存扣减
*
* @param productId 商品ID
* @param amount 扣减数量
* @return 是否扣减成功
*/
public boolean deductStockWithLua(String productId, int amount) {
// 使用try-with-resources自动关闭Jedis连接
try (Jedis jedis = jedisPool.getResource()) {
// 使用StringBuilder拼接长脚本,提升可读性
StringBuilder script = new StringBuilder();
script.append("local key = KEYS[1] ");
script.append("local amount = tonumber(ARGV[1]) ");
script.append("local stock = tonumber(redis.call('get', key) or 0) ");
script.append("if stock < amount then ");
script.append(" return 0 ");
script.append("else ");
script.append(" redis.call('decrby', key, amount) ");
script.append(" return 1 ");
script.append("end");
String stockKey = "product:" + productId + ":stock";
List<String> keys = Collections.singletonList(stockKey);
List<String> args = Collections.singletonList(String.valueOf(amount));
// 执行Lua脚本并明确类型转换
Long result = (Long) jedis.eval(script.toString(), keys, args);
// 判断执行结果
return result == 1;
} catch (Exception e) {
// 使用日志框架替代printStackTrace
logger.error("商品{}库存扣减失败", productId, e);
return false;
}
}
6.2 生产级实现:脚本缓存与异常处理
在生产环境中,我们可以使用脚本缓存进一步提升性能:
/**
* 使用脚本缓存优化的库存扣减实现
*/
public class RedisLuaStockService {
private final JedisPool jedisPool;
private String stockDeductSha;
private static final Logger logger = LoggerFactory.getLogger(RedisLuaStockService.class);
// 提前定义好的库存扣减脚本
private static final String STOCK_DEDUCT_SCRIPT =
"local key = KEYS[1] " +
"local amount = tonumber(ARGV[1]) " +
"if key == nil or key == '' then " +
" return -1 " + // 参数校验:非法Key
"end " +
"if amount == nil or amount <= 0 then " +
" return -2 " + // 参数校验:非法数量
"end " +
"local stock = tonumber(redis.call('get', key) or 0) " +
"if stock < amount then " +
" return 0 " + // 库存不足
"else " +
" redis.call('decrby', key, amount) " +
" return 1 " + // 扣减成功
"end";
/**
* 构造函数,初始化并加载脚本
*
* @param jedisPool Redis连接池
*/
public RedisLuaStockService(JedisPool jedisPool) {
this.jedisPool = jedisPool;
// 初始化时加载脚本
this.initScript();
}
/**
* 初始化并加载Lua脚本
*/
@PostConstruct
private void initScript() {
try (Jedis jedis = jedisPool.getResource()) {
// 将脚本加载到Redis并获取SHA1标识
stockDeductSha = jedis.scriptLoad(STOCK_DEDUCT_SCRIPT);
logger.info("库存扣减脚本加载成功,SHA1: {}", stockDeductSha);
} catch (Exception e) {
logger.error("Redis脚本加载失败", e);
throw new RuntimeException("Redis脚本加载失败", e);
}
}
/**
* 使用Lua脚本执行库存扣减
*
* @param productId 商品ID
* @param amount 扣减数量
* @return 是否扣减成功
*/
public boolean deductStock(String productId, int amount) {
String stockKey = "product:" + productId + ":stock";
List<String> keys = Collections.singletonList(stockKey);
List<String> args = Collections.singletonList(String.valueOf(amount));
try (Jedis jedis = jedisPool.getResource()) {
try {
// 使用EVALSHA执行脚本(比EVAL更高效)
Object result = jedis.evalsha(stockDeductSha, keys, args);
if (result instanceof Long) {
Long code = (Long) result;
if (code == 1L) {
return true; // 扣减成功
} else if (code == 0L) {
logger.info("商品{}库存不足", productId);
return false; // 库存不足
} else if (code == -1L) {
logger.warn("库存键值无效");
return false; // 非法Key
} else if (code == -2L) {
logger.warn("扣减数量{}无效", amount);
return false; // 非法数量
}
}
return false;
} catch (JedisNoScriptException e) {
// 脚本不存在,重新加载(可能是Redis重启)
logger.warn("脚本不存在,正在重新加载...");
stockDeductSha = jedis.scriptLoad(STOCK_DEDUCT_SCRIPT);
Object result = jedis.evalsha(stockDeductSha, keys, args);
return Long.valueOf(1L).equals(result);
}
} catch (Exception e) {
logger.error("商品{}库存扣减失败", productId, e);
return false;
}
}
}
这里的优化点是什么?想象成点餐的例子:
- 普通方式:每次去餐厅都要详细描述一道复杂菜的做法:"请用中火煎牛排三分钟,翻面再煎两分钟,加黑胡椒和迷迭香..."
- 优化方式:第一次去餐厅时把复杂菜谱登记在菜单上并编号,之后只需说"我要 28 号套餐"就能点到同样的菜
7. 高并发场景优化
在高并发场景下,我们可以使用批量处理进一步提升性能:
/**
* 批量处理库存扣减请求
*
* @param productIds 商品ID列表
* @param amount 每个商品的扣减数量
* @return 每个商品扣减结果
*/
public List<Boolean> batchDeductStock(List<String> productIds, int amount) {
if (productIds == null || productIds.isEmpty() || amount <= 0) {
return Collections.emptyList();
}
try (Jedis jedis = jedisPool.getResource()) {
String script = "local key = KEYS[1] " +
"local amount = tonumber(ARGV[1]) " +
"local stock = tonumber(redis.call('get', key) or 0) " +
"if stock < amount then " +
" return 0 " +
"else " +
" redis.call('decrby', key, amount) " +
" return 1 " +
"end";
// 使用pipeline批量执行(避免多次网络往返)
Pipeline pipeline = jedis.pipelined();
for (String productId : productIds) {
String stockKey = "product:" + productId + ":stock";
pipeline.eval(script, Collections.singletonList(stockKey),
Collections.singletonList(String.valueOf(amount)));
}
List<Object> results = pipeline.syncAndReturnAll();
return results.stream()
.map(result -> Long.valueOf(1L).equals(result))
.collect(Collectors.toList());
} catch (Exception e) {
logger.error("批量扣减库存失败", e);
return productIds.stream()
.map(id -> Boolean.FALSE)
.collect(Collectors.toList());
}
}
8. Redis Lua 脚本高级应用
8.1 增强版分布式锁
分布式锁是微服务架构中的关键组件,使用 Lua 脚本可以确保其原子性和安全性:
/**
* 使用Lua脚本实现带自动续期的分布式锁
*
* @param lockKey 锁的Key
* @param requestId 请求标识(用于识别锁的持有者)
* @param expireTime 锁过期时间(毫秒)
* @param autoRenewTime 自动续期时间(毫秒)
* @return 是否成功获取锁
*/
public boolean acquireLock(String lockKey, String requestId, int expireTime, int autoRenewTime) {
try (Jedis jedis = jedisPool.getResource()) {
String script = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
" redis.call('pexpire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
List<String> keys = Collections.singletonList(lockKey);
List<String> args = Arrays.asList(requestId, String.valueOf(expireTime));
Object result = jedis.eval(script, keys, args);
if (Long.valueOf(1L).equals(result)) {
// 启动后台线程自动续期
startAutoRenewal(lockKey, requestId, autoRenewTime);
return true;
}
return false;
}
}
/**
* 释放分布式锁
*
* @param lockKey 锁的Key
* @param requestId 请求标识
* @return 是否成功释放锁
*/
public boolean releaseLock(String lockKey, String requestId) {
try (Jedis jedis = jedisPool.getResource()) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
List<String> keys = Collections.singletonList(lockKey);
List<String> args = Collections.singletonList(requestId);
// 执行脚本并验证结果
Long result = (Long) jedis.eval(script, keys, args);
return result == 1L;
}
}
为什么要用 requestId?举个例子:这就像酒店房卡,不仅要确保开的是正确的房间(lockKey),还要确保是你的房卡(requestId)才能开门,防止别人的房卡误开你的房间。
8.2 高性能限流器
限流是 API 保护的重要手段,Lua 脚本可以实现精确的滑动窗口限流:
/**
* 实现基于Redis的滑动窗口限流器
*
* @param userId 用户ID
* @param actionKey 操作标识
* @param period 时间窗口(毫秒)
* @param maxCount 最大允许次数
* @return 是否允许操作
*/
public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
try (Jedis jedis = jedisPool.getResource()) {
String key = String.format("ratelimit:%s:%s", userId, actionKey);
String script = "local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local period = tonumber(ARGV[2]) " +
"local maxCount = tonumber(ARGV[3]) " +
"-- 移除过期的数据 " +
"redis.call('zremrangebyscore', key, 0, now - period) " +
"-- 获取当前行为次数 " +
"local count = redis.call('zcard', key) " +
"-- 是否超出限制 " +
"if count < maxCount then " +
" -- 使用毫秒级时间戳+唯一标识防止冲突 " +
" redis.call('zadd', key, now, now .. ':' .. ARGV[4]) " +
" redis.call('expire', key, math.ceil(period / 1000)) " +
" return 1 " +
"else " +
" return 0 " +
"end";
List<String> keys = Collections.singletonList(key);
long now = System.currentTimeMillis();
// 添加唯一标识避免重复元素
String uniqueId = UUID.randomUUID().toString();
List<String> args = Arrays.asList(
String.valueOf(now),
String.valueOf(period),
String.valueOf(maxCount),
uniqueId
);
Long result = (Long) jedis.eval(script, keys, args);
return result == 1L;
} catch (Exception e) {
logger.error("限流失败,用户: {}, 操作: {}", userId, actionKey, e);
// 出错时默认放行,避免系统完全不可用
return true;
}
}
这个限流器就像电影院的检票口:在指定时间内(period)只允许进入特定数量(maxCount)的观众,超过人数就需要等待。
9. 性能测试对比
实际测试数据证明了 Lua 脚本的性能优势:
方案 | 平均响应时间 | TPS | 网络流量 |
---|---|---|---|
多命令模式 | 12.5ms | 8,000 | 45MB |
Lua 脚本模式 | 3.2ms | 31,250 | 12MB |
缓存脚本模式 | 2.1ms | 47,619 | 8MB |
测试环境:4 核 8G 服务器,Redis 6.2,Java 11,10 万次库存扣减操作
直观理解:普通方式就像每次去 ATM 都要输入卡号密码,而 Lua 脚本方式像是一次登录后快速完成多个操作。
10. 生产环境监控指标
在生产环境中,需要密切关注以下指标确保 Lua 脚本执行正常:
监控项 | 采集方式 | 阈值建议 |
---|---|---|
lua_time_limiter_hits | INFO SERVER 中的 lua_time_limiter | <10 次/分钟 |
script_cpu_percent | Redis 监控工具(如 redislabs) | <5% CPU 使用率 |
evalsha_miss_rate | 自定义统计脚本(script exists) | <0.1% |
script_exec_time | redis-cli --latency | P99 < 1ms |
11. 故障排查案例
案例:Lua 脚本执行超时导致 Redis 阻塞
问题:每天早上 9 点,Redis 就会变得异常缓慢,大量请求超时。通过排查发现:
- 现象:业务响应时间突然升高,Redis INFO 显示
blocked_clients
超过 100 - 排查:
- 执行
SCRIPT KILL
命令尝试终止卡死的脚本 - 通过
redis-cli --stat
发现操作 QPS 骤降 - 分析慢日志发现某脚本包含
KEYS *
全库扫描
- 执行
原来是有人在脚本中使用了全库扫描来查找商品库存,导致 Redis 卡死:
-- 有问题的代码(全库扫描会锁住Redis)
local keys = redis.call('KEYS', 'product:*:stock')
正确的修改方式应该是使用 SCAN 命令:
-- 正确的代码(使用SCAN增量式扫描)
local cursor = 0
local keys = {}
repeat
local result = redis.call('SCAN', cursor, 'MATCH', 'product:*:stock', 'COUNT', 100)
cursor = tonumber(result[1])
for i, key in ipairs(result[2]) do
table.insert(keys, key)
end
until cursor == 0
这就像图书馆找书,不要一次性把所有书架锁住查找,而是一个书架一个书架地查,不影响其他人使用。
12. 使用注意事项
- 脚本执行时间控制:
- 控制脚本执行时间,避免长时间阻塞 Redis
- 设置合理的
lua-time-limit
(默认 5 秒)
- 原子性的边界:
- Redis Cluster 模式下,脚本只能操作相同 slot 的 key
- 不要在脚本中执行阻塞命令(如 BLPOP)
- 脚本调试技巧:
- 使用
redis-cli --eval script.lua key1 , arg1 arg2
进行测试 - 在脚本中添加
redis.log(redis.LOG_WARNING, "debug info")
输出调试信息
- 异常处理:
- 妥善处理脚本执行异常,包括 JedisNoScriptException
- 关键业务添加降级机制,避免 Redis 故障导致整体不可用
最佳实践流程
总结
优化维度 | 原实现方式 | 优化后方式 | 收益 |
---|---|---|---|
代码可读性 | 长字符串拼接 | 字符串构建器/外部脚本文件 | 维护成本降低 30% |
执行性能 | EVAL 直接执行 | SCRIPT LOAD+EVALSHA | 网络开销减少 60% |
异常处理 | 简单 try-catch | 分级异常处理+日志追踪 | 问题定位效率提升 50% |
安全性 | 未做脚本校验 | 增加 Key/参数合法性检查 | 防止非法参数导致系统异常 |
高并发支持 | 单命令执行 | 流水线+批量处理 | TPS 提升 2-3 倍 |
原子性 | 多次网络往返的非原子操作 | 单次执行的原子操作 | 完全解决并发一致性问题 |