活动票务与支付订单模块——高并发库存扣减、分布式事务与异步一致性

_

一、抢票/候补/保证金的库存扣减核心逻辑

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模式在下单环节的具体实现

参与的服务和表:

服务

数据库/表

事务操作

order-service

order_db.trade_order

INSERT 创建订单

stock-service

stock_db.activity_ticket

UPDATE 冻结库存(frozen_count +N)

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:

Consumer Group

处理内容

失败影响

points-consumer

发放购票积分

用户少得积分,可补发

membership-consumer

更新会员等级/成长值

等级延迟更新,影响小

notification-consumer

发送购票成功通知(微信/短信)

用户没收到通知,可重发

ticket-consumer

生成电子票、更新票务状态

用户看不到电子票,需及时修复

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%"——不是说过程中永远不出差异,而是所有差异最终都被发现和修正了。

APS 生产计划、三维工厂孪生体与设备 TPM 闭环管理 2025-02-16
一次 Full GC 引发的生产事故:从定位到根治的 JVM 调优全记录 2026-01-17

评论区