一、分桶后多张票购买的跨桶一致性
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 13. 补偿失败的兜底——定时对账
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 # 超时未结算的冻结记录数