用 Redis + Lua 守住打赏原子性:我在单体系统中的微观实践(续)

作者:旷野说
定位:Spring Boot 开发者速查手册 + 高并发系统设计认知指南
关键澄清:Redis Lua 不是"炫技",而是高并发交易场景下守住原子性的最后一道微观防线;脚本管理、调试与集成效率,直接决定系统能否在资损边缘稳住阵脚。


用 Redis + Lua 守住打赏原子性:我在单体系统中的微观实践

在"C 端"的打赏系统里,我们曾因"先查余额再扣款"的非原子操作,导致用户被多次扣款

那一刻我明白:高并发下的业务完整性,不能靠"事务"包打天下,而要下沉到 Redis + Lua 的原子执行层

今天,我就以"用户赠送心动钻石"为例,手把手带你搭建一套 "脚本可管、逻辑原子、高并发安全" 的 Lua 实践体系------从 IDEA 编写到 Spring Boot 集成,再到压测兜底,全部落地于单体应用。


一、为什么非得用 Lua?------因为"快"和"对"必须兼得

很多人问:"用 @Transactional 不行吗?"

在低频场景可以。但在打赏这种 5000+ QPS 的高频交易中,DB 会成为瓶颈,而"查 → 判 → 扣"三步网络往返,哪怕 1ms 延迟,也会在并发下放大成超卖。

Redis + Lua 的优势

  • 单线程执行:整个脚本原子执行,无中间态
  • 零网络往返:逻辑在 Redis 内部完成
  • 逻辑内聚:防重、扣款、记日志一步到位

💡 典型原子单元:

"若用户未打赏过此礼物 → 扣钻石 → 记录流水 → 返回成功"


二、项目结构:让 Lua 脚本"可维护、可追溯"

我们把脚本当作一等公民管理,而非硬编码字符串:

复制代码
src/
└── main/
    ├── java/
    │   └── com.zuiyou.gift.service.GiftService.java
    └── resources/
        └── lua/
            ├── deduct_diamond.lua      <-- 扣钻石原子脚本
            └── claim_gift_idempotent.lua  <-- 幂等领取

好处

  • Git 可追踪变更
  • 支持 IDE 语法高亮
  • 单元测试可直接加载

三、IDEA + EmmyLua:让 Lua 像 Java 一样好写

虽然 Lua 脚本不编译,但写错一个 end 就会导致运行时失败

我们的做法:

  1. 安装插件 :IDEA → Marketplace → 搜索 EmmyLua
  2. 获得
    • 语法高亮
    • 括号自动补全
    • 函数跳转(配合注释)
  3. 不追求"运行" ,但确保"写对"

📌 提醒:别在 Java 字符串里拼 Lua!维护成本极高,且无法格式化。


四、Maven:为 Lua 脚本加上"语法守门员"

Lua 脚本虽不参与编译,但上线前必须语法校验 。我们用 Maven 插件在 process-resources 阶段自动检查:

xml 复制代码
<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>exec-maven-plugin</artifactId>
  <version>3.1.0</version>
  <configuration>
    <executable>luac</executable>
    <workingDirectory>${project.basedir}/src/main/resources/lua</workingDirectory>
    <arguments>
      <argument>-p</argument> <!-- 仅解析,不生成字节码 -->
      <argument>deduct_diamond.lua</argument>
    </arguments>
  </configuration>
  <executions>
    <execution>
      <phase>process-resources</phase>
      <goals><goal>exec</goal></goals>
    </execution>
  </executions>
</plugin>

效果

  • mvn clean package 时自动校验
  • 语法错误直接构建失败,不让问题进测试环境
    ⚠️ 需在 CI/CD 机器安装 luac,或用 Docker 容器执行校验。

五、Spring Boot + Redisson:安全加载与执行

我们选用 Redisson(非 Jedis/Lettuce),因其对 Lua 脚本支持最完善。

1. 依赖
xml 复制代码
<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.27.2</version>
</dependency>
2. Lua 脚本(resources/lua/deduct_diamond.lua
lua 复制代码
-- KEYS[1]: user_balance_key (e.g., "balance:123")
-- KEYS[2]: claimed_set_key   (e.g., "gift:claimed:gift_001")
-- ARGV[1]: user_id
-- ARGV[2]: amount
-- ARGV[3]: gift_id

-- 防重:已领取?
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return 0
end

-- 扣余额
local balance = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
if not balance or balance < tonumber(ARGV[2]) then
    return -1  -- 余额不足
end

redis.call('HINCRBY', KEYS[1], ARGV[1], -tonumber(ARGV[2]))
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], 86400)

return 1  -- 成功
3. Java 服务层
java 复制代码
@Service
public class DiamondService {

    private final RedissonClient redisson;
    private final RScript script;

    public DiamondService(RedissonClient redisson) {
        this.redisson = redisson;
        // 从 classpath 安全加载脚本
        String lua = ResourceUtil.readUtf8Str("lua/deduct_diamond.lua");
        this.script = redisson.getScript(StringCodec.INSTANCE);
    }

    public boolean tryDeduct(Long userId, int amount, String giftId) {
        String balanceKey = "balance:" + userId;
        String claimedKey = "gift:claimed:" + giftId;

        Boolean result = script.eval(
            RScript.Mode.READ_WRITE,  // 保证主从同步安全
            lua,
            Arrays.asList(balanceKey, claimedKey),
            userId.toString(), String.valueOf(amount), giftId
        );

        return Boolean.TRUE.equals(result);
    }
}

关键设计

  • READ_WRITE 模式:写操作必须走主库,避免主从延迟导致不一致
  • 脚本预加载:Redisson 自动缓存 SHA,避免重复传输
  • 防重 + 扣款 + 过期 一体化,杜绝超发

六、高并发下的兜底措施(我们踩过的坑)

仅写对 Lua 还不够,还需运行时防护

问题 解法
脚本执行慢 监控 Redis SLOWLOG,Lua 逻辑必须 O(1)
Redis 宕机 降级走 DB(SELECT FOR UPDATE),但限流至 100 QPS
脚本 Bug 所有操作写入 gift_log 表,供对账补偿
Key 冲突 gift:{env}:claimed:{giftId} 隔离多环境

七、调试技巧:在资损前发现问题

  1. 本地 Redis 启动时加 --loglevel verbose,查看 Lua 执行日志

  2. 在 Lua 中加调试日志

    lua 复制代码
    redis.log(redis.LOG_WARNING, "User " .. ARGV[1] .. " deduct " .. ARGV[2])
  3. 单元测试 :用 embedded-redis 启动内存实例,验证边界条件

    java 复制代码
    @Test
    void testInsufficientBalance() {
        // SET balance:123 100
        // 调用 tryDeduct(123, 200, "gift_001")
        // 断言返回 false
    }

Redis Lua 实践口诀(我的血泪总结)

脚本独立管,IDE 高亮看;
Maven 守语法,上线不翻船;
Redisson 加载稳,READ_WRITE 保主从;
防重扣款一体成,原子性在 Redis 中;
慢日志要监控,降级兜底不能松。


结语:微观决定成败

在高并发系统中,一个 Lua 脚本的健壮性,可能比十个微服务拆分更重要

我们没有一上来就"上云上中台",而是在单体内部,把 Redis Lua 做到极致------

  • 脚本可维护
  • 执行可监控
  • 异常可补偿

结果:打赏资损率连续 18 个月为 0

高并发的可靠性,不在宏大的架构图里,而在每一行 Lua 脚本的严谨中。

如需"幂等打赏""限量抢购"等具体 Lua 示例,欢迎留言交流!

相关推荐
想做后端的前端1 小时前
Lua基本数据类型
java·junit·lua
永不停歇的蜗牛1 小时前
解决方法:在本地电脑安装的Centos虚拟机上启动redis服务,使用本地电脑客户端无法连接到redis。
linux·redis·centos
z***56561 小时前
GO 快速升级Go版本
开发语言·redis·golang
D***y2011 小时前
Redis服务安装自启动(Windows版)
数据库·windows·redis
k***82511 小时前
Redis-配置文件
数据库·redis·oracle
shuair1 小时前
redis大key问题-scan、hscan、sscan、zscan等命令
redis
s***38561 小时前
docker中配置redis
redis·docker·容器
爬山算法1 小时前
Redis(155)Redis的数据持久化如何优化?
数据库·redis·bootstrap
小二李1 小时前
第9章 Node框架实战篇 - Redis 缓存
redis·node.js