Redis Lua 脚本:Java 开发中的原子操作解决方案

129 阅读12分钟

Redis 的 Lua 脚本功能是 Java 开发者解决分布式原子操作的强大工具。通过在 Redis 服务端执行 Lua 脚本,我们可以实现复杂的原子逻辑,大幅提升系统性能和数据一致性。

1. 高并发库存系统的挑战

想象一个电商场景:你负责开发库存管理系统,需要确保在数万用户同时抢购时,每件商品都不会超卖。这个看似简单的需求在分布式环境中却面临巨大挑战。

一个典型的库存扣减逻辑包含三个步骤:

  1. 检查商品库存是否充足
  2. 如果充足,扣减库存
  3. 返回操作结果

如果这三步不是原子操作,在高并发下就会出现严重问题。

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 时,发生了什么?下图展示了完整流程:

脚本工作原理.png

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 元,否则不取",一次交流就完成了所有操作。

图示对比:

图示.png

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.5ms8,00045MB
Lua 脚本模式3.2ms31,25012MB
缓存脚本模式2.1ms47,6198MB

测试环境:4 核 8G 服务器,Redis 6.2,Java 11,10 万次库存扣减操作

直观理解:普通方式就像每次去 ATM 都要输入卡号密码,而 Lua 脚本方式像是一次登录后快速完成多个操作。

10. 生产环境监控指标

在生产环境中,需要密切关注以下指标确保 Lua 脚本执行正常:

监控项采集方式阈值建议
lua_time_limiter_hitsINFO SERVER中的 lua_time_limiter<10 次/分钟
script_cpu_percentRedis 监控工具(如 redislabs)<5% CPU 使用率
evalsha_miss_rate自定义统计脚本(script exists)<0.1%
script_exec_timeredis-cli --latencyP99 < 1ms

11. 故障排查案例

案例:Lua 脚本执行超时导致 Redis 阻塞

问题:每天早上 9 点,Redis 就会变得异常缓慢,大量请求超时。通过排查发现:

  • 现象:业务响应时间突然升高,Redis INFO 显示blocked_clients超过 100
  • 排查
    1. 执行SCRIPT KILL命令尝试终止卡死的脚本
    2. 通过redis-cli --stat发现操作 QPS 骤降
    3. 分析慢日志发现某脚本包含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. 使用注意事项

  1. 脚本执行时间控制
  • 控制脚本执行时间,避免长时间阻塞 Redis
  • 设置合理的lua-time-limit(默认 5 秒)
  1. 原子性的边界
  • Redis Cluster 模式下,脚本只能操作相同 slot 的 key
  • 不要在脚本中执行阻塞命令(如 BLPOP)
  1. 脚本调试技巧
  • 使用redis-cli --eval script.lua key1 , arg1 arg2进行测试
  • 在脚本中添加redis.log(redis.LOG_WARNING, "debug info")输出调试信息
  1. 异常处理
  • 妥善处理脚本执行异常,包括 JedisNoScriptException
  • 关键业务添加降级机制,避免 Redis 故障导致整体不可用

最佳实践流程

流程.png

总结

优化维度原实现方式优化后方式收益
代码可读性长字符串拼接字符串构建器/外部脚本文件维护成本降低 30%
执行性能EVAL 直接执行SCRIPT LOAD+EVALSHA网络开销减少 60%
异常处理简单 try-catch分级异常处理+日志追踪问题定位效率提升 50%
安全性未做脚本校验增加 Key/参数合法性检查防止非法参数导致系统异常
高并发支持单命令执行流水线+批量处理TPS 提升 2-3 倍
原子性多次网络往返的非原子操作单次执行的原子操作完全解决并发一致性问题