库存分桶跨桶一致性、Seata+Redis 补偿机制与保证金场景

_

一、分桶后多张票购买的跨桶一致性

1. 问题本质

上一轮讲的分桶 Lua 脚本有一个简化——它从 hash(orderId) % bucketCount 开始遍历桶,找到第一个"够"的桶就扣。这在 quantity=1 时没问题,但如果用户一次买 3 张票,而每个桶剩余可用都只有 1-2 张,就必须跨桶凑。如果第一个桶扣了 1 张、第二个桶扣了 2 张,中间任何一步失败,已经扣掉的那 1 张怎么回滚?这在多条 Redis 命令里没法保证原子性,但在单个 Lua 脚本里可以。

核心原则是:整个跨桶扣减的"试探+扣减"全部放在一个 Lua 脚本里执行,Redis 单线程保证整个脚本要么全部执行完、要么全部不执行(脚本内部出错会全部回滚已执行的命令)。实际上 Lua 脚本里不存在"中间失败已执行的命令回滚"这个能力,所以我们的做法是两阶段:先扫描计算,再批量扣减

2. 跨桶 Lua 脚本的具体实现

-- KEYS: 动态传入,格式为:
--   KEYS[1]    = purchased:{activity_id}  (用户限购 HSet)
--   KEYS[2..N] = stock:{activity_id}:{ticket_type}:{bucket_0..N-1}  (各桶总库存)
--   KEYS[N+1..2N] = frozen:{activity_id}:{ticket_type}:{bucket_0..N-1} (各桶冻结数)
-- ARGV[1] = userId
-- ARGV[2] = quantity (要买的总数)
-- ARGV[3] = maxPerUser (单人限购)
-- ARGV[4] = bucketCount
-- ARGV[5] = startBucket (hash路由的起始桶)
-- ARGV[6] = saleStatus expected value
​
local userId = ARGV[1]
local totalNeed = tonumber(ARGV[2])
local maxPerUser = tonumber(ARGV[3])
local bucketCount = tonumber(ARGV[4])
local startBucket = tonumber(ARGV[5])
​
-- ========== Phase 1: 纯读检查,不做任何写操作 ==========
​
-- 1. 检查活动状态
local statusKey = 'activity:status:' .. string.match(KEYS[2], '(act_%w+)')
local saleStatus = redis.call('GET', statusKey)
if not saleStatus or saleStatus ~= '1' then
    return cjson.encode({code = -1, msg = 'not_on_sale'})
end
​
-- 2. 检查限购
local userBought = tonumber(redis.call('HGET', KEYS[1], userId)) or 0
if userBought + totalNeed > maxPerUser then
    return cjson.encode({code = -2, msg = 'purchase_limit'})
end
​
-- 3. 扫描所有桶,计算分配方案
local allocations = {}  -- {bucketIndex, deductCount} 的数组
local remaining = totalNeed
​
for i = 0, bucketCount - 1 do
    local idx = (startBucket + i) % bucketCount
    -- stockKeys从KEYS[2]开始,frozenKeys从KEYS[2+bucketCount]开始
    local stockKey = KEYS[2 + idx]
    local frozenKey = KEYS[2 + bucketCount + idx]
    
    local stock = tonumber(redis.call('GET', stockKey)) or 0
    local frozen = tonumber(redis.call('GET', frozenKey)) or 0
    local available = stock - frozen
    
    if available > 0 then
        local take = math.min(available, remaining)
        table.insert(allocations, {idx, take})
        remaining = remaining - take
        if remaining == 0 then
            break
        end
    end
end
​
-- 4. 如果凑不够,直接返回失败(此时没有任何写操作发生)
if remaining > 0 then
    return cjson.encode({code = -3, msg = 'insufficient_stock', 
                         shortage = remaining})
end
​
-- ========== Phase 2: 确认可分配后,批量执行写操作 ==========
​
local bucketDetails = {}
for _, alloc in ipairs(allocations) do
    local idx = alloc[1]
    local take = alloc[2]
    local frozenKey = KEYS[2 + bucketCount + idx]
    
    redis.call('INCRBY', frozenKey, take)
    table.insert(bucketDetails, {bucket = idx, count = take})
end
​
-- 更新用户已购数
redis.call('HINCRBY', KEYS[1], userId, totalNeed)
​
return cjson.encode({
    code = 1, 
    msg = 'success',
    allocations = bucketDetails  -- 返回分配明细,DB层需要按桶更新
})

3. 为什么这个脚本能保证原子性

关键在于两阶段分离:Phase 1 全部是读操作(GET/HGET),不修改任何数据;只有当 Phase 1 确认"所有桶凑起来够用"之后,Phase 2 才批量执行写操作(INCRBY/HINCRBY)。

如果凑不够,在 Phase 1 结束时直接 return 失败码,Phase 2 根本不会执行,不存在"部分扣了"的情况。

如果凑够了进入 Phase 2,由于整个 Lua 脚本在 Redis 里是原子执行的(单线程,不会被其他命令插入),Phase 2 里的多个 INCRBY 要么全部完成要么全部不完成(Redis 服务崩溃除外,那是另一个级别的容灾问题)。

4. 返回分配明细给调用层

Lua 脚本返回 JSON 包含 allocations 数组,告诉调用层"桶0扣了1张,桶2扣了2张"。这个信息后续在 Seata 全局事务里用于更新对应桶的 DB 记录:

public StockFreezeResult freezeStock(FreezeStockRequest request) {
    // 1. 执行跨桶Lua
    String luaResult = redisTemplate.execute(crossBucketFreezeScript, keys, args);
    LuaResponse response = JSON.parseObject(luaResult, LuaResponse.class);
    
    if (response.getCode() != 1) {
        return StockFreezeResult.fail(response.getMsg());
    }
    
    // 2. 按分配明细更新各桶的DB记录
    for (BucketAllocation alloc : response.getAllocations()) {
        int affected = ticketBucketMapper.incrFrozenCount(
            request.getActivityId(),
            request.getTicketType(),
            alloc.getBucket(),     // 指定桶ID
            alloc.getCount()       // 该桶冻结数量
        );
        if (affected == 0) {
            throw new BizException("DB frozen count update failed, bucket=" 
                                    + alloc.getBucket());
        }
    }
    
    // 3. 记录库存流水(含分桶明细,回滚时用)
    stockLogMapper.insert(StockLog.builder()
        .orderId(request.getOrderId())
        .activityId(request.getActivityId())
        .ticketType(request.getTicketType())
        .quantity(request.getQuantity())
        .allocations(JSON.toJSONString(response.getAllocations()))
        .action("FREEZE")
        .build());
    
    return StockFreezeResult.success(response.getAllocations());
}

二、Seata 全局事务回滚时 Redis 冻结库存的补偿

1. 核心矛盾

Seata AT 模式只能自动回滚 DB 操作(通过 undo_log),但 Redis 的 INCRBY 不在 Seata 的管控范围内。如果全局事务回滚了(比如订单创建成功但库存服务 DB 写入超时),DB 里的 frozen_count 被 undo_log 回滚了,但 Redis 里的 frozen key 还是 INCRBY 后的值——这就出现了 Redis 和 DB 不一致,而且是Redis 多冻结的方向,意味着可用库存被少算了,实际表现就是明明还有票但用户抢不到。

2. 补偿方案:Seata GlobalTransactionHook + 主动 Redis 回滚

Seata 提供了 TransactionHook 接口,可以在全局事务的各个阶段注入回调。我们利用 afterRollback 钩子来补偿 Redis:

@Component
public class StockRollbackHook {
​
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private StockLogMapper stockLogMapper;
​
    /**
     * 在下单入口方法里注册Hook
     */
    public void registerRollbackHook(String orderId) {
        TransactionHookManager.registerHook(new TransactionHookAdapter() {
            @Override
            public void afterRollback() {
                compensateRedis(orderId);
            }
        });
    }
​
    private void compensateRedis(String orderId) {
        try {
            // 从库存流水表查出这笔订单的分桶分配明细
            StockLog log = stockLogMapper.selectByOrderId(orderId);
            if (log == null || !"FREEZE".equals(log.getAction())) {
                return; // 没执行过冻结,无需补偿
            }
​
            List<BucketAllocation> allocations = 
                JSON.parseArray(log.getAllocations(), BucketAllocation.class);
            
            // 执行Redis回滚Lua脚本(释放冻结 + 恢复用户限购计数)
            redisTemplate.execute(rollbackFreezeScript,
                buildRollbackKeys(log),
                log.getUserId(),
                String.valueOf(log.getQuantity()),
                JSON.toJSONString(allocations)
            );
            
            log.info("Redis stock compensated for rolled-back order: {}", orderId);
            
            // 更新流水状态为已补偿
            stockLogMapper.updateAction(orderId, "FREEZE_ROLLED_BACK");
            
        } catch (Exception e) {
            // 补偿失败,记录到补偿失败表,由定时任务兜底
            log.error("Redis compensation failed for order: {}", orderId, e);
            compensationFailMapper.insert(new CompensationFail(
                orderId, "REDIS_ROLLBACK", e.getMessage()));
        }
    }
}

回滚用的 Lua 脚本——释放各桶冻结数并恢复用户限购计数:

-- 回滚Lua:精确释放各桶的冻结数量
-- ARGV[1] = userId
-- ARGV[2] = totalQuantity
-- ARGV[3] = allocations JSON: [{"bucket":0,"count":1},{"bucket":2,"count":2}]
​
local userId = ARGV[1]
local totalQuantity = tonumber(ARGV[2])
local allocations = cjson.decode(ARGV[3])
​
for _, alloc in ipairs(allocations) do
    local frozenKey = KEYS[1 + alloc.bucket]  -- frozen:{activity}:{type}:{bucketN}
    local current = tonumber(redis.call('GET', frozenKey)) or 0
    local deduct = math.min(current, alloc.count)  -- 防止减成负数
    if deduct > 0 then
        redis.call('DECRBY', frozenKey, deduct)
    end
end
​
-- 恢复用户限购计数
local purchasedKey = KEYS[#KEYS]  -- 最后一个key是purchased HSet
local userBought = tonumber(redis.call('HGET', purchasedKey, userId)) or 0
local restore = math.min(userBought, totalQuantity)
if restore > 0 then
    redis.call('HINCRBY', purchasedKey, userId, -restore)
end
​
return 1

3. 补偿失败的兜底——定时对账

afterRollback 钩子不是 100% 可靠的——如果 order-service 本身在回滚过程中 JVM 崩溃了,钩子根本不会执行。所以还有一层兜底:

@XxlJob("redis-stock-compensation-retry")
public void retryFailedCompensations() {
    // 1. 查补偿失败表里未处理的记录
    List<CompensationFail> failures = compensationFailMapper
        .selectUnprocessed(100);
    
    for (CompensationFail fail : failures) {
        try {
            // 重新检查订单状态:如果订单确实不存在或已取消,执行Redis补偿
            Order order = orderMapper.selectById(fail.getOrderId());
            if (order == null || order.getStatus() == OrderStatus.CANCELLED) {
                compensateRedis(fail.getOrderId());
                compensationFailMapper.markProcessed(fail.getId());
            }
        } catch (Exception e) {
            log.error("Compensation retry failed: {}", fail.getOrderId(), e);
            // 重试次数+1,超过5次告警
            compensationFailMapper.incrRetryCount(fail.getId());
            if (fail.getRetryCount() >= 5) {
                alertService.send("Redis补偿重试超限", 
                    "orderId=" + fail.getOrderId());
            }
        }
    }
}

另外,前面提到的每 5 分钟 stock-reconciliation 对账任务也是最终兜底——它直接比对 Redis 冻结数和 DB 冻结数,不管差异是什么原因造成的,统一以 DB 为准修正。

4. 这套补偿机制踩过的坑

坑:afterRollback 钩子在异步回滚时不触发

Seata 的全局事务有同步回滚和异步回滚两种。正常情况下,@GlobalTransactional 方法抛异常后 TC 同步通知各分支回滚,afterRollback 钩子正常触发。但有一种场景:全局事务超时(我们配的 30 秒),TC 主动发起异步回滚——这时 order-service 的线程可能已经释放了,TransactionHook 注册在 ThreadLocal 里的钩子不会被执行。

排查过程: 库存对账任务发现某天有 3 笔"Redis 冻结数 > DB 冻结数"的差异,查 stock_log 表发现这 3 笔订单的状态都是 CANCELLED(被 Seata 回滚了),但 stock_log 里的 action 还是 FREEZE,没有 FREEZE_ROLLED_BACK 记录——说明 Redis 补偿根本没执行。再查 Seata TC 的日志,发现这 3 笔全是超时异步回滚触发的。

解决方案: 不能只依赖 TransactionHook。增加了一层基于 stock_log 的扫描补偿任务——每分钟扫描一次 stock_log 表,查找"action=FREEZE 且 对应订单状态不是 UNPAID/PAID"的记录(说明订单被取消或回滚了但 Redis 补偿没执行),对这些记录执行 Redis 回滚:

@XxlJob("stock-log-scan-compensator")
public void scanAndCompensate() {
    // 查找"已冻结但订单已死"的流水
    List<StockLog> orphanedFreezes = stockLogMapper.selectOrphanedFreezes();
    // SQL: SELECT sl.* FROM stock_log sl 
    //      JOIN trade_order o ON sl.order_id = o.order_id
    //      WHERE sl.action = 'FREEZE' 
    //      AND o.status NOT IN ('UNPAID', 'PAID')
    //      AND sl.created_at < NOW() - INTERVAL 2 MINUTE
    
    for (StockLog log : orphanedFreezes) {
        compensateRedis(log.getOrderId());
    }
    
    metricsRegistry.counter("stock_orphan_compensation_total")
        .increment(orphanedFreezes.size());
}

所以 Redis 补偿最终是三层保障:TransactionHook 即时补偿(覆盖大部分场景)→ stock-log 扫描补偿(每分钟,覆盖异步回滚等边界场景)→ 全量对账修正(每 5 分钟,终极兜底)。


三、保证金场景的特殊处理

1. 保证金的业务场景

趣玩搭平台有一类活动是"保证金制"——用户报名时不直接买票,而是交一笔保证金(通常是票价的 30%-50%),目的是防止恶意占位。活动开始前 N 小时确认参加后,保证金抵扣票款补差价;如果用户不来或超时未确认,保证金按规则扣罚一部分(比如 50%)作为违约金,剩余退回。

这和普通票务的区别在于:保证金有"冻结→确认→抵扣/扣罚→退回"多个状态流转,而且涉及钱包余额的冻结与解冻,不仅仅是库存的增减。

2. 保证金的数据模型

在普通票务模型基础上增加了一张 deposit_record 表:

CREATE TABLE deposit_record (
    id BIGINT PRIMARY KEY,
    order_id VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    activity_id VARCHAR(64) NOT NULL,
    club_id VARCHAR(64) NOT NULL,
    deposit_amount DECIMAL(10,2) NOT NULL,      -- 保证金金额
    penalty_ratio DECIMAL(3,2) DEFAULT 0.50,    -- 违约扣罚比例
    status ENUM('FROZEN','CONFIRMED','PENALTY','REFUNDED','DEDUCTED') NOT NULL,
    frozen_at DATETIME NOT NULL,
    confirmed_at DATETIME,
    settled_at DATETIME,
    version INT DEFAULT 0,                      -- 乐观锁
    UNIQUE INDEX uk_order_id (order_id)
);

状态流转:

FROZEN(交保证金)
  ├── CONFIRMED(用户确认参加)→ DEDUCTED(保证金抵扣票款)
  └── 超时未确认 → PENALTY(扣罚违约金)→ REFUNDED(退回剩余部分)

3. 保证金冻结的扣减流程

和普通票务不同,保证金场景需要同时操作两个资源:活动名额用户钱包余额

@GlobalTransactional(name = "deposit-freeze-tx", timeoutMills = 30000)
public DepositFreezeResult freezeDeposit(DepositFreezeRequest request) {
    
    // 1. 活动名额冻结(和普通票务一样走Redis Lua + DB)
    StockFreezeResult stockResult = stockFreezeService.freezeStock(
        FreezeStockRequest.from(request));
    if (!stockResult.isSuccess()) {
        throw new BizException(stockResult.getMessage());
    }
    
    // 2. 钱包余额冻结(不是直接扣款,是从可用余额转到冻结余额)
    WalletFreezeResult walletResult = walletService.freezeBalance(
        request.getUserId(), request.getDepositAmount(), request.getOrderId());
    if (!walletResult.isSuccess()) {
        throw new BizException("余额不足,无法冻结保证金");
    }
    
    // 3. 创建保证金记录
    DepositRecord record = DepositRecord.builder()
        .orderId(request.getOrderId())
        .userId(request.getUserId())
        .activityId(request.getActivityId())
        .clubId(request.getClubId())
        .depositAmount(request.getDepositAmount())
        .penaltyRatio(request.getPenaltyRatio())
        .status(DepositStatus.FROZEN)
        .frozenAt(LocalDateTime.now())
        .build();
    depositRecordMapper.insert(record);
    
    // 4. 发送确认超时延迟消息(活动开始前N小时)
    mqProducer.sendDepositConfirmTimeout(request.getOrderId(), 
        request.getConfirmDeadline());
    
    return DepositFreezeResult.success(record.getId());
}

钱包冻结的实现:

@Service
public class WalletService {
    
    @Transactional
    public WalletFreezeResult freezeBalance(Long userId, BigDecimal amount, 
                                             String orderId) {
        // 乐观锁更新:可用余额减少,冻结余额增加
        int affected = walletMapper.freezeBalance(userId, amount);
        // SQL: UPDATE user_wallet 
        //      SET available_balance = available_balance - #{amount},
        //          frozen_balance = frozen_balance + #{amount},
        //          version = version + 1
        //      WHERE user_id = #{userId} 
        //      AND available_balance >= #{amount}
        //      AND version = #{expectedVersion}
        
        if (affected == 0) {
            return WalletFreezeResult.fail("余额不足");
        }
        
        // 记录钱包流水
        walletLogMapper.insert(WalletLog.builder()
            .userId(userId)
            .type("DEPOSIT_FREEZE")
            .amount(amount.negate())  // 可用余额减少
            .relatedOrderId(orderId)
            .build());
        
        return WalletFreezeResult.success();
    }
}

4. 保证金确认与抵扣

用户在截止时间前确认参加,保证金抵扣票款:

@GlobalTransactional(name = "deposit-confirm-tx", timeoutMills = 30000)
public void confirmDeposit(String orderId) {
    DepositRecord record = depositRecordMapper.selectByOrderId(orderId);
    if (record.getStatus() != DepositStatus.FROZEN) {
        throw new BizException("保证金状态不正确");
    }
    
    BigDecimal ticketPrice = getTicketPrice(record.getActivityId());
    BigDecimal supplement = ticketPrice.subtract(record.getDepositAmount());
    
    if (supplement.compareTo(BigDecimal.ZERO) > 0) {
        // 需要补差价:从可用余额扣款(或引导微信支付)
        walletService.deductBalance(record.getUserId(), supplement, orderId);
    }
    
    // 保证金从冻结余额转为已消费(不退回)
    walletService.settleFrozenBalance(record.getUserId(), 
        record.getDepositAmount(), orderId);
    
    // 冻结库存转为真实扣减
    stockService.confirmDeduct(orderId);
    
    // 更新保证金状态
    depositRecordMapper.updateStatus(orderId, DepositStatus.CONFIRMED, 
                                     DepositStatus.FROZEN);
    // ... 后续更新为 DEDUCTED
}

5. 超时未确认的扣罚流程

通过 RocketMQ 延迟消息触发:

@RocketMQMessageListener(topic = "deposit-confirm-timeout-topic")
public class DepositTimeoutConsumer {
    
    public void onMessage(DepositTimeoutDTO dto) {
        DepositRecord record = depositRecordMapper.selectByOrderId(dto.getOrderId());
        
        // 幂等检查
        if (record == null || record.getStatus() != DepositStatus.FROZEN) {
            return;
        }
        
        BigDecimal penaltyAmount = record.getDepositAmount()
            .multiply(record.getPenaltyRatio());          // 违约金
        BigDecimal refundAmount = record.getDepositAmount()
            .subtract(penaltyAmount);                      // 退回金额
        
        // 1. 扣罚违约金(从冻结余额扣除,转入平台/俱乐部账户)
        walletService.penaltyFromFrozen(
            record.getUserId(), penaltyAmount, 
            record.getClubId(), dto.getOrderId());
        
        // 2. 退回剩余保证金(从冻结余额转回可用余额)
        if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
            walletService.unfreezeBalance(
                record.getUserId(), refundAmount, dto.getOrderId());
        }
        
        // 3. 释放活动名额(Redis冻结释放 + DB冻结释放)
        stockService.releaseFrozen(dto.getOrderId());
        
        // 4. 更新保证金状态
        depositRecordMapper.updateStatus(dto.getOrderId(), 
            DepositStatus.PENALTY, DepositStatus.FROZEN);
        
        // 5. 触发候补队列(有名额释放了)
        waitlistService.tryFillFromWaitlist(
            record.getActivityId(), record.getTicketType());
        
        // 6. 通知用户
        notificationService.sendDepositPenaltyNotice(
            record.getUserId(), penaltyAmount, refundAmount);
    }
}

6. 保证金与普通票务的关键差异

差异一:Seata 全局事务多了钱包服务

普通票务的 Seata 事务只涉及 order-service 和 stock-service 两个分支。保证金场景多了 wallet-service 这个分支(冻结余额操作),全局事务从 2 个分支变成 3 个,全局锁竞争更复杂。

我们的处理方式是钱包的冻结/解冻操作按 user_id 分片——每个用户的钱包只有一行记录,不同用户之间没有全局锁冲突。所以即使多了一个分支,高并发下的瓶颈还是在库存桶(多个用户抢同一个活动的同一个桶),钱包不是瓶颈。

差异二:钱包冻结也需要 Redis 补偿

和库存冻结类似,钱包余额的冻结操作如果 Seata 回滚了,DB 通过 undo_log 回滚没问题,但如果有用到 Redis 做钱包余额缓存(我们确实有一个 wallet:balance:{userId} 的缓存用于快速判断余额是否足够),这个缓存也需要补偿。

实际处理比库存简单——因为钱包缓存只是做前置校验(余额够不够),不是扣减的数据源。所以钱包缓存的一致性策略是 Cache-Aside:每次钱包 DB 写操作成功后删除缓存(redisTemplate.delete("wallet:balance:" + userId)),下次查询时从 DB 重建。Seata 回滚时 DB 数据被恢复,缓存在 afterRollback 钩子里也主动删除,下次查询自然读到回滚后的正确值。这比库存的冻结补偿简单很多,不需要精确的 DECRBY 回滚。

差异三:保证金的金额精度要求更高

库存是整数操作(扣 1 张、扣 2 张),天然没有精度问题。保证金涉及金额计算(票价 × 比例 = 保证金、保证金 × 扣罚比例 = 违约金),必须用 BigDecimal,所有除法运算指定 RoundingMode.HALF_UP,保留 2 位小数。有一次上线前测试发现一个 bug:违约金计算用了 double 导致 49.99 × 0.5 = 24.994999... 而不是 25.00,退回金额和扣罚金额加起来不等于原始保证金,对账出现了 1 分钱的差异。改成 BigDecimal 后彻底解决。

7. 保证金一致性的监控与验证

保证金对账在每日全量对账里加了一个专门维度:

// 保证金对账
public List<DiffRecord> reconcileDeposits(LocalDate date) {
    // 1. 每笔保证金记录:冻结金额 = 扣罚金额 + 退回金额(已结算的)
    List<DepositRecord> settled = depositRecordMapper
        .selectSettledByDate(date);
    
    List<DiffRecord> diffs = new ArrayList<>();
    for (DepositRecord record : settled) {
        BigDecimal penalty = walletLogMapper.sumByOrderAndType(
            record.getOrderId(), "DEPOSIT_PENALTY");
        BigDecimal refund = walletLogMapper.sumByOrderAndType(
            record.getOrderId(), "DEPOSIT_REFUND");
        BigDecimal consumed = walletLogMapper.sumByOrderAndType(
            record.getOrderId(), "DEPOSIT_CONSUME");
        
        BigDecimal totalSettled = penalty.add(refund).add(consumed);
        
        if (totalSettled.compareTo(record.getDepositAmount()) != 0) {
            diffs.add(new DiffRecord("DEPOSIT", record.getOrderId(),
                "expected=" + record.getDepositAmount() 
                + " actual=" + totalSettled));
        }
    }
    
    // 2. 检查是否有"FROZEN状态超过48小时未结算"的异常记录
    List<DepositRecord> stale = depositRecordMapper
        .selectStaleFrozen(Duration.ofHours(48));
    for (DepositRecord record : stale) {
        diffs.add(new DiffRecord("DEPOSIT_STALE", record.getOrderId(),
            "frozen for >48h without settlement"));
    }
    
    return diffs;
}

Prometheus 指标:

deposit_freeze_total              # 保证金冻结总笔数
deposit_confirm_total             # 确认参加笔数
deposit_penalty_total             # 违约扣罚笔数
deposit_amount_frozen_yuan        # 当前冻结中的保证金总额(按活动维度)
deposit_reconciliation_diff_total # 保证金对账差异笔数
deposit_stale_frozen_count        # 超时未结算的冻结记录数

四、生产真实数据汇总

指标

数值

备注

抢票场景峰值 QPS

800-1000(Redis Lua 层),300-400(进入 Seata 事务层)

60%-70% 被 Lua 前置过滤

分桶后下单 TPS

550-600

分桶前只有 120(全局锁瓶颈)

Seata 全局事务回滚率

约 0.3%-0.5%

主要是库存不足的正常业务回滚

Redis 补偿成功率

TransactionHook 即时补偿覆盖约 95%,stock-log 扫描补偿覆盖剩余 4.5%,全量对账兜底 0.5%

三层叠加后 100% 最终一致

库存 Redis-DB 对账差异次数

上线半年共 5 次

全部由 5 分钟对账任务自动修正

保证金金额对账差异

上线后 0 次

BigDecimal 修复后无精度问题

保证金超时未结算(>48h)

上线至今 2 次

均为 RocketMQ 延迟消息偶发延迟,人工触发结算后解决

MQ 死信数(累计半年)

十几条

主要是积分服务连接池问题,已修复

synchronized 与 ReentrantLock:从原理到实战选型的深度总结 2026-02-01
跨桶 Lua Java 侧调用、补偿机制生产效果与保证金事务权衡 2026-02-02

评论区