跨桶 Lua Java 侧调用、补偿机制生产效果与保证金事务权衡

_

一、跨桶 Lua 脚本在 Java 侧的实际调用实现

1. 动态构造 KEYS 和 ARGV 的核心问题

跨桶 Lua 脚本的 KEYS 数量取决于 bucketCount,5 个桶就是 1(purchased HSet)+ 5(stock keys)+ 5(frozen keys)= 11 个 KEYS,10 个桶就是 21 个。Spring 的 RedisTemplate.execute(RedisScript, List<String> keys, Object... args) 的 keys 参数是 List<String>,可以动态构建,这部分不难。但有几个实际的坑需要处理。

2. 完整的 Java 调用代码

@Component
public class CrossBucketStockOperator {
​
    @Autowired
    private StringRedisTemplate redisTemplate;
​
    /**
     * Lua脚本在服务启动时加载一次,缓存SHA1避免每次传输脚本全文
     */
    private final DefaultRedisScript<String> crossBucketFreezeScript;
​
    public CrossBucketStockOperator() {
        this.crossBucketFreezeScript = new DefaultRedisScript<>();
        crossBucketFreezeScript.setLocation(
            new ClassPathResource("lua/cross_bucket_freeze.lua"));
        crossBucketFreezeScript.setResultType(String.class);
    }
​
    public LuaResponse freezeStock(String activityId, String ticketType,
                                    String userId, int quantity,
                                    int maxPerUser, String orderId) {
        
        // 从配置中心读取桶数(不同活动可能不同)
        int bucketCount = getBucketCount(activityId);
        int startBucket = Math.abs(orderId.hashCode()) % bucketCount;
​
        // ===== 动态构建KEYS =====
        List<String> keys = new ArrayList<>(1 + bucketCount * 2);
        
        // KEYS[1]: 用户限购HSet
        keys.add(String.format("purchased:%s", activityId));
        
        // KEYS[2 .. bucketCount+1]: 各桶总库存
        for (int i = 0; i < bucketCount; i++) {
            keys.add(String.format("stock:%s:%s:%d", 
                activityId, ticketType, i));
        }
        
        // KEYS[bucketCount+2 .. 2*bucketCount+1]: 各桶冻结数
        for (int i = 0; i < bucketCount; i++) {
            keys.add(String.format("frozen:%s:%s:%d", 
                activityId, ticketType, i));
        }
​
        // ===== ARGV =====
        String[] argv = new String[] {
            userId,                          // ARGV[1]
            String.valueOf(quantity),         // ARGV[2]
            String.valueOf(maxPerUser),       // ARGV[3]
            String.valueOf(bucketCount),      // ARGV[4]
            String.valueOf(startBucket),      // ARGV[5]
            activityId                        // ARGV[6] 用于拼活动状态key
        };
​
        // ===== 执行 =====
        String result = redisTemplate.execute(
            crossBucketFreezeScript, keys, argv);
        
        return JSON.parseObject(result, LuaResponse.class);
    }
​
    private int getBucketCount(String activityId) {
        // 从Nacos配置或DB读取,热门活动配10桶,普通活动5桶
        ActivityConfig config = activityConfigCache.get(activityId);
        return config != null ? config.getBucketCount() : 5;
    }
}

3. EVALSHA 与脚本缓存

这里有一个性能细节:DefaultRedisScript 配合 Spring RedisTemplate 使用时,Spring 底层自动走 EVALSHA。第一次调用时 Spring 会先尝试 EVALSHA <sha1>,如果 Redis 返回 NOSCRIPT(脚本未缓存),Spring 自动 fallback 到 EVAL <full_script> 并且 Redis 会缓存 SHA1。后续调用全走 EVALSHA,只传 SHA1 摘要(40字节),不传完整脚本文本。

我们的跨桶 Lua 脚本大约 80 行、2KB 左右。如果每次都传完整脚本,在 800 并发下网络开销不可忽略。EVALSHA 机制下传输量降低了 98%。

但踩过一个坑:Redis 集群模式下 EVALSHA 的节点漂移问题。我们后来从单节点 Redis 迁移到了 3 主 3 从的 Redis Cluster。Lua 脚本的 SHA1 缓存是每个节点独立的,如果请求被路由到一个新 failover 上来的从节点(刚升主),它没有 SHA1 缓存,会返回 NOSCRIPT。Spring 的 fallback 机制能处理这个问题(自动重发 EVAL),但我们在迁移初期没注意到日志里频繁出现的 NOSCRIPT 重试,导致 p99 延迟出现了一些毛刺。

解决方式是在应用启动时(以及 Redis failover 事件监听到时),主动对所有 Redis 节点执行 SCRIPT LOAD

@Component
public class LuaScriptPreloader implements ApplicationRunner {
​
    @Autowired
    private StringRedisTemplate redisTemplate;
​
    @Override
    public void run(ApplicationArguments args) {
        String scriptText = loadScriptFromClasspath("lua/cross_bucket_freeze.lua");
        // 对连接池里的连接执行SCRIPT LOAD
        redisTemplate.execute((RedisCallback<String>) connection -> {
            return connection.scriptingCommands()
                .scriptLoad(scriptText.getBytes());
        });
        log.info("Lua scripts preloaded to Redis");
    }
}

4. Lua 脚本执行超时的处理

Redis 默认的 Lua 执行超时是 5 秒(lua-time-limit 配置)。我们的脚本在正常情况下执行时间约 0.5-1ms(10 个桶遍历一遍,每个桶两次 GET),远低于限制。

但压测时发现了一个极端场景:Redis 正在做 RDB 持久化时(fork 子进程),Lua 脚本的执行延迟会抖动。T4 服务器内存 16GB,Redis 占用约 2GB,RDB fork 时 copy-on-write 在高写入并发下偶尔导致主线程阻塞 50-100ms。这不会触发 5 秒超时,但对 p99 有可感知的影响。

解决方式是把 RDB 持久化频率降低(从默认的 save 60 10000 改成 save 300 10000),同时 AOF 用 appendfsync everysec 保证数据安全。极端情况下 Redis 主线程阻塞导致 Lua 执行慢,Gateway 层的 Sentinel 熔断会兜底。

5. KEYS 数量限制的实际考量

Redis 对 Lua 脚本的 KEYS 数量没有硬编码上限,但Redis Cluster 模式有一个关键约束:所有 KEYS 必须属于同一个 hash slot。如果 stock:act_1001:standard:0stock:act_1001:standard:1 被分配到不同 slot,脚本执行会报 CROSSSLOT 错误。

我们的解法是用 hash tag:在 key 中用 {} 包裹一段公共前缀,Redis Cluster 只根据 {} 内的部分计算 slot。改造后的 key 格式:

// 改造前(可能跨slot)
"stock:act_1001:standard:0"
"frozen:act_1001:standard:3"
​
// 改造后(同一个hash tag,同一slot)
"stock:{act_1001:standard}:0"
"frozen:{act_1001:standard}:3"
"purchased:{act_1001:standard}"

所有同一活动同一票种的 key 共享 {act_1001:standard} 这个 hash tag,保证落在同一个 slot,Lua 脚本可以安全执行。

Java 侧构建 key 的代码对应调整:

// KEYS构建时统一使用hash tag格式
String hashTag = String.format("{%s:%s}", activityId, ticketType);
keys.add(String.format("purchased:%s", hashTag));
for (int i = 0; i < bucketCount; i++) {
    keys.add(String.format("stock:%s:%d", hashTag, i));
}
for (int i = 0; i < bucketCount; i++) {
    keys.add(String.format("frozen:%s:%d", hashTag, i));
}

二、Seata 补偿机制的真实生产效果

1. 不一致发生的次数与分布

上线半年的完整统计数据(从 stock_reconciliation_log 表和 Prometheus 指标汇总):

时间段

Redis-DB 不一致次数

触发原因

修复方式

上线第1个月

8 次

TransactionHook 机制还没加 stock-log 扫描补偿,异步回滚场景漏补偿

5 分钟对账任务修正

第2个月

3 次

加了 stock-log 扫描后,仅剩 TC 超时极端场景

stock-log 扫描补偿修正 2 次,对账修正 1 次

第3-6个月

2 次

1 次是 Redis Cluster failover 导致补偿 Lua 执行 MOVED,1 次是 stock_log 表连接超时

对账修正

总计 13 次不一致,全部在发生后 5 分钟内被自动修正,没有任何一次需要人工介入数据修复。

2. 各层补偿的覆盖率

从 Prometheus 指标统计每一层实际触发补偿的比例(以 Seata 回滚的总次数为分母):

Seata全局事务回滚总次数(半年):约 1,200 次
  ├── 业务正常回滚(库存不足等):约 1,150 次 → 这些在Lua层就拒绝了,
  │   Redis根本没执行INCRBY,无需补偿
  └── 非业务回滚(超时/异常/TC故障):约 50 次 → 需要Redis补偿
       ├── TransactionHook 即时补偿成功:46 次(92%)
       ├── stock-log 扫描补偿成功:3 次(6%)
       └── 5分钟全量对账修正:1 次(2%)

核心结论:需要 Redis 补偿的场景本身就很少(50/1200 ≈ 4%),因为绝大部分回滚是"库存不足"这种业务拒绝——Lua 脚本直接返回 -3,Redis 根本没做任何写入,不需要补偿。真正需要补偿的是"Lua 执行成功了、DB 也写了、但 Seata 全局事务回滚"的场景,这种情况每月约 8-10 次。

3. 有没有极端案例——补偿也修不了

坦白说,遇到过一次比较惊险的情况,发生在上线第一个月:

某次 Redis Cluster 做了一次计划内的主从切换(运维在非高峰期做的),切换的 10 秒窗口里恰好有 2 笔 Seata 回滚触发了 TransactionHook,但补偿 Lua 脚本执行时遇到了 MOVED 重定向(Redis 客户端的 slot 映射还没刷新),Lua 执行失败。失败后写入了 compensation_fail 表,但 redis-stock-compensation-retry 定时任务也失败了(因为 Redis 客户端连接池里缓存的 slot 映射还是旧的)。

最终是 5 分钟对账任务兜底的——对账任务用的是独立的 Redis 连接(每次新建),不依赖缓存的 slot 映射,所以能正确连到新主节点并修正数据。

这之后改进了两点:一是 Redis 客户端配置加了 enablePeriodicRefresh(Duration.ofSeconds(30))(Lettuce 的拓扑刷新),slot 映射每 30 秒自动刷新;二是 compensation_fail 的重试逻辑里加了 redisTemplate.getConnectionFactory().resetConnection() 强制刷新连接。

所以回答"有没有补偿和对账都修不了的":没有。三层叠加后,最终的全量对账是直接对比 Redis 值和 DB 值然后以 DB 为准覆盖写,这个操作只会在对账任务本身连不上 Redis 或连不上 DB 时才会失败——那属于基础设施级别的故障,不是补偿机制设计能解决的范围了。

4. 补偿相关的 Prometheus 指标

// 在各补偿组件里埋的指标
@Component
public class CompensationMetrics {
​
    private final MeterRegistry registry;
​
    // 1. TransactionHook补偿
    public void recordHookCompensation(boolean success) {
        registry.counter("seata_redis_compensation_total",
            "layer", "transaction_hook",
            "result", success ? "success" : "failure"
        ).increment();
    }
​
    // 2. stock-log扫描补偿
    public void recordScanCompensation(int count) {
        registry.counter("seata_redis_compensation_total",
            "layer", "stock_log_scan",
            "result", "success"
        ).increment(count);
        // 同时记录扫描发现的孤儿冻结数
        registry.gauge("stock_orphan_freeze_current", count);
    }
​
    // 3. 全量对账修正
    public void recordReconciliationFix(int count) {
        registry.counter("seata_redis_compensation_total",
            "layer", "reconciliation",
            "result", "success"
        ).increment(count);
    }
​
    // 4. 补偿失败(进入compensation_fail表)
    public void recordCompensationFailure(String orderId, String reason) {
        registry.counter("seata_redis_compensation_failure_total",
            "reason", reason
        ).increment();
    }
​
    // 5. 补偿重试超限
    public void recordRetryExhausted(String orderId) {
        registry.counter("seata_compensation_retry_exhausted_total")
            .increment();
        // 这个指标 > 0 立即告警
    }
}

Grafana 面板上有一个"库存补偿"区域:

  • 补偿触发趋势图:三层补偿的触发次数堆叠柱状图,按天展示。正常情况 transaction_hook 层占绝大多数,如果 stock_log_scan 或 reconciliation 层突然增多,说明某些异常场景在增加。

  • 补偿失败率compensation_failure_total / compensation_total,告警阈值 > 5%。

  • 当前孤儿冻结数stock_orphan_freeze_current gauge,> 0 持续超过 10 分钟告警。

  • 重试超限计数compensation_retry_exhausted_total,> 0 立即 PagerDuty 告警(意味着有冻结无法自动回滚)。

实际运行中 compensation_retry_exhausted_total 始终为 0——因为即使 TransactionHook 和 stock-log 扫描都失败,5 分钟对账的暴力覆盖写总能兜底。


三、保证金场景的事务权衡

1. 增加 wallet-service 分支后的性能影响

实测数据(压测环境,200 并发):

指标

普通票务(2 分支)

保证金(3 分支)

差值

下单接口 p50

180ms

260ms

+80ms

下单接口 p99

650ms

980ms

+330ms

Seata 全局事务耗时 p50

120ms

195ms

+75ms

Seata 全局事务耗时 p99

400ms

720ms

+320ms

TPS

550-600

380-420

-30%

p50 增加不多(+80ms),主要是 wallet-service 的一次 DB 操作开销。但 p99 增加比较明显(+330ms),原因是 3 个分支事务意味着 Seata TC 需要协调 3 次分支提交/回滚,全局锁的持有时间变长,在高并发下锁等待的长尾效应更显著。

TPS 下降约 30%,从 550 降到 400 左右。但这在业务上是可以接受的——保证金制活动通常是中小规模(100-300 人),瞬间并发不会像热门抢票那样到 800,实际峰值大概 200-300 并发,400 TPS 完全够用。

2. 回滚率对比

场景

回滚率

主要回滚原因

普通票务

0.3%-0.5%

库存不足(Lua 过滤后漏网的极少数)、网络超时

保证金

0.8%-1.2%

上述原因 + 钱包余额不足

保证金回滚率更高主要因为多了"余额不足"这个失败点。Lua 脚本只能检查库存,没法检查钱包余额(余额在另一个服务的 DB 里)。虽然下单前前端会做余额校验,但存在并发场景——用户余额 100 元,同时点了两个 50 元保证金的活动,两个请求几乎同时通过前端校验,但第二个请求到 wallet-service 时余额已经被第一个冻结了,触发回滚。

优化方案:在进入 Seata 全局事务之前,加一层钱包余额的 Redis 预检查。和库存类似,钱包也在 Redis 里维护一个可用余额的近似值(不需要精确,只做前置过滤),大部分"余额不足"的请求在这一步就被拒绝了,不用进 Seata 事务再回滚。

public DepositFreezeResult freezeDeposit(DepositFreezeRequest request) {
    // 前置检查1:Redis Lua扣库存
    LuaResponse stockResult = crossBucketStockOperator.freezeStock(...);
    if (stockResult.getCode() != 1) {
        return DepositFreezeResult.fail(stockResult.getMsg());
    }
    
    // 前置检查2:钱包余额Redis预检
    String balanceKey = "wallet:available:" + request.getUserId();
    String cachedBalance = redisTemplate.opsForValue().get(balanceKey);
    if (cachedBalance != null) {
        BigDecimal available = new BigDecimal(cachedBalance);
        if (available.compareTo(request.getDepositAmount()) < 0) {
            // 余额不足,需要先回滚已冻结的库存
            crossBucketStockOperator.rollbackFreeze(...);
            return DepositFreezeResult.fail("余额不足");
        }
    }
    // cachedBalance为null时(缓存未命中)不拦截,让Seata事务内的DB校验兜底
    
    // 进入Seata全局事务
    return doFreezeWithSeata(request);
}

加了这层预检后,保证金的回滚率从 1.2% 降到了 0.4% 左右,和普通票务差不多。

3. 为什么不改成 MQ 最终一致性

这是我们内部讨论过的最核心的权衡点。改成 MQ 异步的架构大概是这样:下单时只在 order-service 本地事务里创建订单 + Redis 冻结库存,然后发一条 MQ 消息给 wallet-service 异步冻结余额。

我们担心的业务风险有三个:

风险一:名额占了但钱没冻住。 如果库存冻结成功、MQ 消息也发了,但 wallet-service 消费时发现余额不足——这时候名额已经被占了,需要回滚库存。这个回滚本身不难(发一条补偿消息释放库存),但中间有时间窗口(可能几百毫秒到几秒),这段时间里这个名额被白白占着,其他用户抢不到。对于只有 100 个名额的保证金活动,这个问题比 500 张票的普通活动严重得多——每一个名额都更珍贵。

风险二:保证金场景对"确定性"要求更高。 普通票务是"付款买票",用户习惯了"下单后再付款"的两步流程,中间有 15 分钟窗口期。但保证金是"冻结余额",用户预期的心智模型是"我点了报名,钱立刻被冻住了",如果点了报名显示成功但过了 3 秒才收到"余额不足请充值"的通知,用户体验很差,而且用户已经看到了"报名成功"的页面,再告诉他失败了,会引发投诉。

风险三:扣罚逻辑依赖冻结状态的准确性。 保证金的后续流程(确认参加→抵扣、超时→扣罚)都以"冻结成功"为前提。如果异步冻结还没完成,用户就触发了确认操作,会出现"确认参加了但保证金没冻住"的不一致。虽然可以在确认时做补偿检查,但逻辑变得非常复杂,状态机分支急剧增多,出 bug 的概率大幅上升。

最终决策逻辑: 保证金场景的并发量不高(峰值 200-300,不是抢票的 800+),Seata 3 分支事务的 400 TPS 完全够用。性能上没有必须用 MQ 异步的硬性需求,而 Seata 强一致能把上述三个风险全部消除。如果保证金活动的规模将来增长到需要 1000+ 并发,那时候可以考虑拆成"先同步冻结库存+余额,再异步通知"的混合模式——但现阶段用 Seata 是最务实的选择。

用一句话总结:够用的场景选简单可靠的方案,性能不够了再上复杂架构,不要为了技术优雅提前引入不必要的复杂度。


库存分桶跨桶一致性、Seata+Redis 补偿机制与保证金场景 2026-02-01
SaaS 多租户场景慢 SQL 治理:从 explain 分析到雪花 ID 重构的全过程 2026-02-14

评论区