一、抢票/候补/保证金的库存扣减核心逻辑
1. 为什么用Redis Lua而不是简单的incr/decr
先说为什么不能用简单的decr。抢票场景里库存扣减不是一个单步操作,它至少涉及三个判断:库存够不够、这个用户有没有重复下单(同一活动同一用户限购)、当前活动是否在售卖时间窗口内。如果这三个判断和最终扣减分成多条Redis命令,中间没有原子性保证,在几百上千并发下会出现典型的TOCTOU竞态——线程A检查库存=1,线程B也检查库存=1,两个都认为还有票,都执行decr,最终库存变成-1,超卖了。
用Lua脚本把"检查+判断+扣减"合并成一次原子执行,Redis单线程模型保证了整个脚本执行期间不会被其他命令插入,从根本上消除了竞态条件。
2. Lua脚本的具体实现
我们实际的抢票Lua脚本大致是这样的:
-- KEYS[1]: 库存key stock:{activity_id}:{ticket_type}
-- KEYS[2]: 用户已购集合 purchased:{activity_id}
-- KEYS[3]: 冻结库存key frozen:{activity_id}:{ticket_type}
-- ARGV[1]: 用户ID
-- ARGV[2]: 购买数量
-- ARGV[3]: 活动状态key对应的售卖状态(1=在售)
-- ARGV[4]: 单人限购数量
-- ARGV[5]: 冻结过期时间(秒),用于15分钟未支付释放
-- 1. 检查活动是否在售
local saleStatus = redis.call('GET', 'activity:status:' .. KEYS[1])
if not saleStatus or saleStatus ~= '1' then
return -1 -- 活动未在售
end
-- 2. 检查用户限购
local userBought = redis.call('HGET', KEYS[2], ARGV[1])
userBought = tonumber(userBought) or 0
if userBought + tonumber(ARGV[2]) > tonumber(ARGV[4]) then
return -2 -- 超出限购
end
-- 3. 检查库存(可用库存 = 总库存 - 已冻结)
local stock = tonumber(redis.call('GET', KEYS[1])) or 0
local frozen = tonumber(redis.call('GET', KEYS[3])) or 0
local available = stock - frozen
if available < tonumber(ARGV[2]) then
return -3 -- 库存不足
end
-- 4. 扣减:增加冻结数量(不直接扣总库存,支付成功后才真扣)
redis.call('INCRBY', KEYS[3], ARGV[2])
-- 记录用户已购数量
redis.call('HINCRBY', KEYS[2], ARGV[1], ARGV[2])
return 1 -- 成功几个关键设计点:
冻结库存而非直接扣减。用户下单后库存进入"冻结"状态,不是直接减总库存。原因是用户可能下单不付款,如果直接扣总库存,15分钟后取消订单还要加回来,高并发下这个"扣了再加"的回滚操作很容易出错。用冻结的模式,可用库存 = 总库存 - 冻结数,支付成功后才真正执行DECRBY总库存同时释放冻结数。15分钟未支付则只需释放冻结数,总库存不动。
限购校验原子化。用HGET检查用户已购数量和HINCRBY增加数量放在同一个Lua脚本里,保证不会出现同一用户并发下两个请求都通过限购检查的情况。
返回码区分失败原因。前端可以根据不同的返回码给用户不同的提示——"活动已结束"、"已达限购上限"、"票已售罄",而不是笼统的"下单失败"。
3. 冻结库存的15分钟释放机制
用户下单未支付的冻结释放,用的是RocketMQ延迟消息而不是Redis的key过期回调(keyspace notification不可靠,可能丢失)。
public void sendFreezeTimeoutMessage(String orderId, String activityId,
String ticketType, int quantity) {
Message msg = new Message("order-freeze-timeout-topic",
JSON.toJSONString(new FreezeTimeoutDTO(orderId, activityId,
ticketType, quantity)));
// RocketMQ延迟等级18对应15分钟
msg.setDelayTimeLevel(18);
rocketMQTemplate.syncSend("order-freeze-timeout-topic", msg);
}15分钟后Consumer收到消息,先检查订单状态——如果已支付则忽略,如果未支付则释放冻结库存:
@RocketMQMessageListener(topic = "order-freeze-timeout-topic")
public class FreezeTimeoutConsumer {
public void onMessage(FreezeTimeoutDTO dto) {
// 幂等检查:先查订单状态
Order order = orderService.getById(dto.getOrderId());
if (order == null || order.getStatus() != OrderStatus.UNPAID) {
return; // 已支付或已取消,跳过
}
// 释放冻结库存
redisTemplate.execute(releaseFrozenScript,
List.of(frozenKey(dto), purchasedKey(dto)),
dto.getQuantity(), dto.getUserId());
// 更新订单状态为已取消
orderService.cancel(dto.getOrderId(), "支付超时自动取消");
// 触发候补队列检查
waitlistService.tryFillFromWaitlist(dto.getActivityId(), dto.getTicketType());
}
}4. 候补队列的实现
候补用的是Redis Sorted Set,score是用户加入候补的时间戳(先进先出):
Key: waitlist:{activity_id}:{ticket_type}
Type: ZSet
Score: 报名时间戳
Value: userId:quantity(比如 "user_1024:2" 表示要2张)当有库存释放时(冻结超时、用户主动取消、主理人加票),触发候补补票逻辑:
public void tryFillFromWaitlist(String activityId, String ticketType) {
String waitlistKey = "waitlist:" + activityId + ":" + ticketType;
while (true) {
// 取候补队首
Set<ZSetOperations.TypedTuple<String>> first =
redisTemplate.opsForZSet().rangeWithScores(waitlistKey, 0, 0);
if (first == null || first.isEmpty()) break;
TypedTuple<String> candidate = first.iterator().next();
String[] parts = candidate.getValue().split(":");
String userId = parts[0];
int quantity = Integer.parseInt(parts[1]);
// 尝试用Lua脚本扣库存(和正常抢票走同一个脚本)
Long result = executeStockLua(activityId, ticketType, userId, quantity);
if (result == 1) {
// 扣减成功,移出候补队列
redisTemplate.opsForZSet().remove(waitlistKey, candidate.getValue());
// 自动创建订单,发送支付通知
orderService.createWaitlistOrder(userId, activityId, ticketType, quantity);
notificationService.sendWaitlistSuccess(userId, activityId);
} else if (result == -3) {
// 库存不够这个候补者的数量,跳过试下一个
// 但如果连续3个都不够,说明库存确实不足,退出
break;
} else {
// 其他错误,移出队列(比如用户已限购)
redisTemplate.opsForZSet().remove(waitlistKey, candidate.getValue());
}
}
}5. 超卖/少卖的最终防线
Lua脚本解决了Redis层面的并发安全,但整体链路还有一层兜底——数据库层面的乐观锁。支付成功真正扣减总库存时,不是直接UPDATE stock SET count = count - N,而是带version校验:
UPDATE activity_ticket
SET stock = stock - #{quantity},
version = version + 1
WHERE activity_id = #{activityId}
AND ticket_type = #{ticketType}
AND stock >= #{quantity}
AND version = #{expectedVersion}如果affected rows = 0,说明被其他事务抢先修改了,触发重试。这是最后一道防线,理论上不应该被触发(因为Redis Lua已经保证了并发安全),但作为防御性编程留着。上线至今这个乐观锁冲突的计数器stock_optimistic_lock_conflict_total一直是0。
二、Seata 分布式事务的真实使用场景
1. 先说清楚我们在哪里用了Seata,在哪里没用
这个很重要——不是所有跨服务操作都用Seata。Seata有性能开销(全局锁、TC协调),在高并发场景下用太多会成为瓶颈。我们的原则是:强一致性的核心链路用Seata AT模式,可以容忍短暂不一致的非核心链路用RocketMQ最终一致性。
用Seata AT模式的场景:下单环节——订单服务创建订单 + 库存服务冻结库存。这两步必须要么都成功要么都失败,如果订单创建了但库存没冻结住,用户付了款却没票;如果库存冻结了但订单没创建,库存就被白白占了。
没用Seata、走MQ最终一致性的场景:支付成功后的下游——积分发放、会员等级更新、消息通知、票务状态变更。这些操作允许延迟几秒完成,不需要和支付动作在同一个事务里。
2. Seata AT模式在下单环节的具体实现
参与的服务和表:
AT模式的好处是对业务代码侵入很小,只需要在入口方法上加@GlobalTransactional注解:
@Service
public class OrderCreateService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockFeignClient stockFeignClient; // Feign调用库存服务
@GlobalTransactional(name = "create-order-tx", timeoutMills = 10000)
public CreateOrderResult createOrder(CreateOrderRequest request) {
// 1. 生成分布式ID(Leaf雪花算法)
String orderId = leafIdGenerator.nextId("order");
// 2. 幂等令牌校验(防重复提交)
boolean tokenValid = redisTemplate.delete("order:token:" + request.getToken());
if (!tokenValid) {
throw new BizException("请勿重复提交");
}
// 3. 创建订单记录(状态=待支付)
TradeOrder order = buildOrder(orderId, request);
orderMapper.insert(order);
// 4. Feign调用库存服务冻结库存
// 如果这步失败,Seata会自动回滚step3的insert
StockFreezeResult freezeResult = stockFeignClient.freezeStock(
FreezeStockRequest.builder()
.activityId(request.getActivityId())
.ticketType(request.getTicketType())
.quantity(request.getQuantity())
.orderId(orderId)
.build()
);
if (!freezeResult.isSuccess()) {
throw new BizException(freezeResult.getMessage());
}
// 5. 发送15分钟冻结超时延迟消息
mqProducer.sendFreezeTimeoutMessage(orderId, request);
return CreateOrderResult.success(orderId);
}
}库存服务这边的分支事务:
@Service
public class StockFreezeService {
// 不需要加@GlobalTransactional,Seata AT模式自动识别为分支事务
@Transactional
public StockFreezeResult freezeStock(FreezeStockRequest request) {
// 1. 先执行Redis Lua扣减冻结库存(前面讲的那个脚本)
Long luaResult = redisTemplate.execute(freezeStockScript, ...);
if (luaResult != 1) {
return StockFreezeResult.fail(mapErrorCode(luaResult));
}
// 2. 同步更新DB的冻结库存(AT模式会自动记录undo_log)
int affected = ticketMapper.incrFrozenCount(
request.getActivityId(),
request.getTicketType(),
request.getQuantity()
);
// 3. 记录库存流水
stockLogMapper.insert(new StockLog(request, "FREEZE"));
return StockFreezeResult.success();
}
}3. Seata在高并发下踩过的坑
坑1:全局锁等待导致下单接口超时
上线后第一次热门活动(一个500人的音乐节活动,开售瞬间约800并发),下单接口的p99延迟从正常的500ms飙到了8秒,大量请求超时失败。
排查发现是Seata TC的全局锁争用。AT模式下,Seata会对被修改的行加全局锁(lock_table),同一行数据在一个全局事务提交前,其他全局事务对同一行的修改会被阻塞等待。我们的activity_ticket表里,同一个活动的库存记录就是一行,800个并发订单全部在争抢这一行的全局锁,变成了串行执行。
解决方案分两步。第一步是Redis Lua前置过滤——在进入Seata全局事务之前,先执行Redis Lua脚本扣减冻结库存。这一步是原子的、没有锁争用的,能在Redis层面拦掉库存不足、限购超限等无效请求,实测能过滤掉60%-70%的并发请求,真正进入Seata事务的请求量大幅减少。
第二步是库存分桶(Stock Sharding)。把一个活动500张票的库存不放在一行里,而是拆成5个桶,每桶100张,存在5行记录里:
-- activity_ticket_bucket表
| activity_id | ticket_type | bucket_id | stock | frozen_count |
|-------------|-------------|-----------|-------|--------------|
| act_1001 | standard | 0 | 100 | 0 |
| act_1001 | standard | 1 | 100 | 0 |
| act_1001 | standard | 2 | 100 | 0 |
| act_1001 | standard | 3 | 100 | 0 |
| act_1001 | standard | 4 | 100 | 0 |下单时根据hash(orderId) % bucketCount路由到不同的桶,全局锁从争抢1行变成了分散到5行,吞吐量直接翻了4-5倍。Redis Lua脚本里对应也做了分桶,每个桶一个stock key。
-- 分桶版本的Lua:先随机选一个桶尝试,如果不够再遍历其他桶
local bucketCount = tonumber(ARGV[5])
local startBucket = tonumber(ARGV[6]) -- hash(orderId) % bucketCount
for i = 0, bucketCount - 1 do
local bucket = (startBucket + i) % bucketCount
local stockKey = KEYS[1] .. ':' .. bucket
local frozenKey = KEYS[3] .. ':' .. bucket
local stock = tonumber(redis.call('GET', stockKey)) or 0
local frozen = tonumber(redis.call('GET', frozenKey)) or 0
if stock - frozen >= tonumber(ARGV[2]) then
redis.call('INCRBY', frozenKey, ARGV[2])
redis.call('HINCRBY', KEYS[2], ARGV[1], ARGV[2])
return bucket -- 返回命中的桶ID,后续DB操作也更新这个桶
end
end
return -3 -- 所有桶都不够坑2:Seata TC单点故障
有一次Seata TC Server挂了(OOM,当时只部署了单节点),所有带@GlobalTransactional的请求全部失败。但因为下单入口有try-catch,异常被捕获后返回"系统繁忙请重试",用户端没有出现脏数据,只是短暂不可用。
解决措施:把Seata TC Server改成了3节点集群部署,用DB模式存储全局事务数据(存在MySQL里),3个TC节点共享一个DB,任何一个挂了其他的自动接管。同时加了一个Seata降级开关——如果检测到TC集群整体不可用(连接超时3秒),自动切换到"本地事务+补偿"模式:只在order-service本地事务里创建订单,库存扣减走Redis Lua(不同步DB),等TC恢复后再由补偿任务把Redis和DB的库存对齐。
# Nacos配置
seata:
enabled: true
fallback-to-local: true # 降级开关
tc-connect-timeout-ms: 3000
tc-cluster: seata-tc-cluster坑3:分支事务超时导致数据不一致
有一次库存服务因为GC停顿了6秒,Seata全局事务超时(我们设的10秒),TC发起了回滚。但库存服务的分支事务实际上已经执行成功了(在GC恢复后提交了本地事务),只是回报给TC的时间超了。结果就是:订单被回滚了(因为全局事务失败),但库存的冻结数已经增加了(分支事务本地提交了),Redis和DB的冻结库存凭空多了。
排查发现Seata的undo_log回滚机制在这种边界情况下有问题——分支事务本地提交后,TC发起回滚时应该通过undo_log回滚DB修改,但如果分支事务的完成报告超时了,TC并不确定分支到底有没有提交,处理逻辑比较复杂。
解决方案有两个:一是把全局事务超时从10秒放宽到30秒(覆盖极端GC停顿),同时优化库存服务的JVM参数减少GC时间;二是加了一个定时对账任务(XXL-JOB,每5分钟跑一次),比对Redis冻结库存和DB冻结库存是否一致,如果不一致以DB为准修正Redis(DB有undo_log兜底更可靠),同时告警通知人工确认。
@XxlJob("stock-reconciliation")
public void stockReconciliation() {
List<ActivityTicketBucket> dbBuckets = ticketMapper.selectAllActiveBuckets();
for (ActivityTicketBucket bucket : dbBuckets) {
String redisKey = frozenKey(bucket);
int redisFrozen = Integer.parseInt(
redisTemplate.opsForValue().get(redisKey));
if (redisFrozen != bucket.getFrozenCount()) {
log.warn("Stock mismatch! activity={}, bucket={}, " +
"redis_frozen={}, db_frozen={}",
bucket.getActivityId(), bucket.getBucketId(),
redisFrozen, bucket.getFrozenCount());
// 以DB为准修正Redis
redisTemplate.opsForValue().set(redisKey,
String.valueOf(bucket.getFrozenCount()));
// 告警
alertService.send("库存不一致告警", ...);
}
}
}三、RocketMQ 异步处理与最终一致性
1. 支付成功后的异步通知全流程
支付成功(微信支付回调确认)后,不是同步调用所有下游,而是发一条RocketMQ消息,下游各服务各自消费:
@Service
public class PaymentCallbackService {
@Transactional
public void onPaymentSuccess(PaymentNotification notification) {
// 1. 更新订单状态为已支付(本地事务)
orderMapper.updateStatus(notification.getOrderId(),
OrderStatus.PAID, OrderStatus.UNPAID);
// 2. 真正扣减总库存 + 释放冻结数(本地事务内)
stockService.confirmDeduct(notification.getOrderId());
// 3. 发送事务消息给下游(半消息,确保和本地事务一致)
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
"payment-success-topic",
MessageBuilder.withPayload(
new PaymentSuccessEvent(
notification.getOrderId(),
notification.getUserId(),
notification.getAmount(),
notification.getActivityId()
)
).build(),
null // arg
);
}
}这里用的是RocketMQ事务消息,保证"更新订单状态"和"发送MQ消息"的原子性。如果本地事务提交了但MQ发送失败,RocketMQ的回查机制会定期来查本地事务状态(通过TransactionListener),确认已提交则补发消息。
下游有4个Consumer Group分别消费同一个Topic:
2. 消费幂等设计
每个Consumer的幂等方案是统一的Redis + DB双重去重:
public abstract class IdempotentConsumer<T> {
public void onMessage(MessageExt msg, T payload) {
String msgId = msg.getMsgId();
String bizKey = extractBizKey(payload); // 比如orderId
String idempotentKey = "mq:consumed:" + getGroup() + ":" + bizKey;
// 1. Redis快速去重(大部分重复消费在这里拦截)
Boolean firstTime = redisTemplate.opsForValue()
.setIfAbsent(idempotentKey, "1", Duration.ofHours(48));
if (Boolean.FALSE.equals(firstTime)) {
log.info("Duplicate message ignored: {}", bizKey);
return;
}
try {
// 2. DB级幂等(防止Redis故障时的漏网之鱼)
// 用唯一索引 (consumer_group, biz_key) 插入消费记录
consumeLogMapper.insert(new ConsumeLog(getGroup(), bizKey, msgId));
// 3. 执行真正的业务逻辑
doConsume(payload);
} catch (DuplicateKeyException e) {
// DB唯一索引冲突,说明确实重复了
log.info("DB idempotent check caught duplicate: {}", bizKey);
} catch (Exception e) {
// 业务异常,删掉Redis标记让后续重试能进来
redisTemplate.delete(idempotentKey);
throw e; // 抛出让MQ重试
}
}
protected abstract void doConsume(T payload);
protected abstract String getGroup();
protected abstract String extractBizKey(T payload);
}核心设计点:Redis setIfAbsent做第一层快速去重,DB唯一索引做第二层兜底。业务执行失败时主动删除Redis标记,让MQ重试时能重新进入业务逻辑,避免"标记了已消费但实际没处理成功"的问题。
3. 失败重试与死信处理
RocketMQ的Consumer配了最大重试16次(默认值),重试间隔逐级递增(1s、5s、10s、30s、1min、2min...到最后2小时)。如果16次全部失败,进入死信队列%DLQ%{consumerGroup}。
针对死信队列,我们有一个死信监控Consumer专门消费所有死信Topic,做两件事:一是写入mq_dead_letter表记录明细,二是触发AlertManager告警。运维或开发收到告警后人工排查,修复问题后可以从mq_dead_letter表里重新投递消息。
@RocketMQMessageListener(topic = "%DLQ%points-consumer-group",
consumerGroup = "dead-letter-monitor")
public class DeadLetterMonitor {
public void onMessage(MessageExt msg) {
// 1. 记录到DB
deadLetterMapper.insert(new DeadLetter(
msg.getTopic(), msg.getMsgId(),
new String(msg.getBody()),
msg.getReconsumeTimes()
));
// 2. 告警
alertService.send("MQ死信告警",
String.format("Topic: %s, MsgId: %s, 重试次数: %d",
msg.getTopic(), msg.getMsgId(), msg.getReconsumeTimes()));
}
}实际运行中进入死信的情况非常少,上线半年大概只有十几条,主要是积分服务偶尔因为数据库连接池满了导致连续失败。排查后扩大了积分服务的连接池,问题没再复现。
四、高峰期实战与监控
1. 压测方案
上线前用JMeter模拟热门活动抢票场景:模拟2000个用户在10秒内并发抢购500张票。压测环境和生产环境配置一致(但独立隔离的数据库和Redis)。
压测发现的问题和前面讲的Seata全局锁基本吻合——分桶之前,下单接口的TPS只有120左右(被全局锁串行化了),分桶之后TPS提升到了550-600,满足业务需求(我们最热门的活动瞬间并发也就800-1000,其中60%被Redis Lua前置过滤,真正进入下单流程的约300-400)。
2. 核心Prometheus指标
# 下单接口
order_create_qps # 下单QPS,峰值告警阈值800
order_create_latency_seconds # 下单延迟,p99 > 3s告警
order_create_error_rate # 下单失败率,> 5%告警
# 库存
stock_lua_execution_total # Lua脚本执行总次数,按返回码分类
stock_lua_reject_rate # Lua拒绝率(-1/-2/-3的比例)
stock_frozen_count{activity_id} # 各活动冻结库存实时值
stock_reconciliation_mismatch_total # 对账不一致次数
# Seata
seata_global_tx_total # 全局事务总数
seata_global_tx_rollback_total # 回滚次数,> 0就告警排查
seata_global_lock_wait_ms # 全局锁等待时间
# 支付与MQ
payment_callback_latency_ms # 支付回调处理时间
mq_consume_success_total # 消费成功计数
mq_consume_retry_total # 重试计数,持续增长告警
mq_dead_letter_total # 死信计数,> 0立即告警3. 交易一致性100%的验证方式
"交易一致性100%"是通过每日全量对账来验证的,不是靠某个单一机制保证。对账任务是XXL-JOB每天凌晨2点跑的:
对账维度一:订单-支付对账。拉取当天所有状态为"已支付"的订单和微信支付的交易记录(通过微信支付的"下载对账单"接口),逐笔比对金额、订单号是否一致。差异记录写入reconciliation_diff表。
对账维度二:订单-库存对账。统计每个活动所有已支付订单的购票总数,和数据库里的total_stock - remaining_stock做比对,必须严格相等。
对账维度三:订单-积分对账。统计每个用户当天的购票积分应发总额,和积分流水表的实际发放总额做比对。
@XxlJob("daily-reconciliation")
public void dailyReconciliation() {
LocalDate yesterday = LocalDate.now().minusDays(1);
// 1. 订单-支付对账
List<DiffRecord> paymentDiffs = reconcilePayment(yesterday);
// 2. 订单-库存对账
List<DiffRecord> stockDiffs = reconcileStock(yesterday);
// 3. 订单-积分对账
List<DiffRecord> pointsDiffs = reconcilePoints(yesterday);
// 汇总
int totalDiffs = paymentDiffs.size() + stockDiffs.size() + pointsDiffs.size();
if (totalDiffs > 0) {
alertService.send("每日对账异常",
String.format("支付差异%d笔,库存差异%d笔,积分差异%d笔",
paymentDiffs.size(), stockDiffs.size(), pointsDiffs.size()));
// 写入diff表供人工处理
diffMapper.batchInsert(paymentDiffs);
diffMapper.batchInsert(stockDiffs);
diffMapper.batchInsert(pointsDiffs);
}
// 记录对账结果
reconciliationLogMapper.insert(new ReconciliationLog(
yesterday, totalDiffs, "SUCCESS"
));
}上线运行半年多,订单-支付对账差异为0(微信支付本身很可靠),订单-库存对账出现过2次差异(就是前面讲的Seata分支事务超时那次导致的,被5分钟定时对账及时发现并修正了),订单-积分对账出现过3次差异(积分Consumer消费失败进死信,人工重新投递后补发成功)。所有差异都在当天被发现和修复,没有出现过最终不一致的情况,所以说"交易一致性100%"——不是说过程中永远不出差异,而是所有差异最终都被发现和修正了。