buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

业务解耦的经典实践:从订单表解耦开票业务说起(以滴滴网约车开票为例)

在千万级订单量的系统中,如何优雅地将开票业务从订单核心表中剥离

在滴滴网约车业务中,日订单量超过1千万

滴滴网约车订单的开发票页面,见下方移动端页面截图。业务操作大家应该都知道,勾选一笔或多笔订单,点击“下一步”进行开发票。

滴滴网约车订单的开发票页面

如果按最直观的程序设计方式实现开票需求,流程如下:

  1. 订单表上增加一个“是否开票”字段
  2. 开票页面查询“是否开票=false”的订单
  3. 批量开票后,更新订单表的“是否开票=true”
  4. 开票失败时,再将字段重置为false
  5. 开票历史从开票主表和订单明细表查询

一、这种“需求直译”模式存在两个致命问题

1. 业务耦合

开票逻辑侵入订单核心表。每次开票都要修改订单数据,开票业务与订单核心业务纠缠在一起,任何开票逻辑的变更都可能影响订单主流程。

2. 性能灾难

在千万级订单量的系统中,频繁UPDATE订单表会带来巨大的数据库压力。索引维护、锁竞争、MVCC版本链、主从同步延迟……每一次开票操作都在损耗订单核心表的性能。

核心矛盾:订单表是订单域的核心聚合根,承载着整个订单生命周期的状态流转。而开票只是订单完成后的一个下游业务,不应该直接操作订单表。


so,如何解决这个问题呢? ————问题即答案!

二、解耦方案:引入“待开票订单中间表”

解耦的核心思路是:在订单表和开票系统之间插入一张“待开票订单中间表” ,作为两个系统的边界。

订单表(订单核心域) → 待开票订单中间表(边界) → 开票系统(开票域)

这样做的好处是:

  • 开票业务的所有读写操作都只在中间表和开票相关表上进行,不再触碰订单表
  • 订单核心域与开票域彻底解耦,互不影响
  • 双方均可独立演进

三、数据库设计

1. 待开票订单中间表(order_invoice_pending

CREATE TABLE order_invoice_pending (
    order_id VARCHAR(32) NOT NULL COMMENT '订单ID(主键,关联订单表)',
    user_id VARCHAR(32) NOT NULL COMMENT '用户ID',
    order_amount INT NOT NULL COMMENT '订单金额,单位:分',
    order_create_time DATETIME NOT NULL COMMENT '订单创建时间',
    order_complete_time DATETIME NOT NULL COMMENT '订单完成时间',
    pending_status TINYINT DEFAULT 0 COMMENT '待开票状态: 0-待开票, 1-已开票, 2-已取消',
    invoice_id VARCHAR(32) DEFAULT NULL COMMENT '关联的开票单号',
    sync_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
    PRIMARY KEY (order_id),
    INDEX idx_user_status (user_id, pending_status),
    INDEX idx_status_time (pending_status, order_complete_time)
) COMMENT='待开票订单中间表';

为什么用 order_id 做主键?

一笔订单在中间表中只有一条待开票记录,用 order_id 作为主键既减少了冗余索引,也使得业务语义更清晰——主键本身就是业务标识。

索引设计要点

索引 用途
PRIMARY KEY (order_id) 主键即订单ID,唯一标识一笔订单的待开票记录
idx_user_status (user_id, pending_status) 支撑“查询某用户待开票订单”的高频查询
idx_status_time (pending_status, order_complete_time) 支撑定时任务扫描待处理记录

2. 开票主表(invoice_master

CREATE TABLE invoice_master (
    invoice_id VARCHAR(32) PRIMARY KEY COMMENT '开票单号',
    user_id VARCHAR(32) NOT NULL COMMENT '用户ID',
    invoice_type TINYINT NOT NULL COMMENT '发票类型: 1-电子普票, 2-增值税专票',
    invoice_title VARCHAR(100) COMMENT '发票抬头',
    tax_no VARCHAR(20) COMMENT '纳税人识别号',
    receiver_type TINYINT COMMENT '接收方式: 1-邮箱, 2-短信',
    receiver_address VARCHAR(100) COMMENT '接收地址',
    total_amount INT NOT NULL COMMENT '总开票金额:分',
    order_count INT NOT NULL COMMENT '关联订单数量',
    status TINYINT DEFAULT 0 COMMENT '开票状态: 0-处理中, 1-成功, 2-失败',
    apply_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
    finish_time DATETIME COMMENT '完成时间',
    fail_reason VARCHAR(200) COMMENT '失败原因',
    INDEX idx_user_status (user_id, status)
) COMMENT='开票主表';

3. 开票订单关联表(invoice_order_detail

CREATE TABLE invoice_order_detail (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    invoice_id VARCHAR(32) NOT NULL COMMENT '开票单号',
    order_id VARCHAR(32) NOT NULL COMMENT '订单ID(关联中间表的order_id)',
    amount INT NOT NULL COMMENT '该订单开票金额,单位:分',
    INDEX idx_invoice_id (invoice_id),
    INDEX idx_order_id (order_id),
    UNIQUE KEY uk_order_id (order_id)
) COMMENT='开票订单明细表';

四、数据同步机制

订单完成后,需要将开票所需的数据同步到中间表。推荐使用订单完成事件驱动方案:

订单服务 → 发布“订单已完成”事件 → 消息队列 → 开票同步消费者 → 写入中间表

优势

  • 订单服务和开票服务完全解耦,订单服务不需要知道开票的存在
  • 支持异步处理,不阻塞订单主流程
  • 消息队列提供重试机制,保证数据最终一致性

代码示例(Spring Event + 异步监听)

// 订单完成时发布事件
@Service
public class OrderService {
    @Transactional
    public void completeOrder(String orderId) {
        // ... 订单完成逻辑
        applicationEventPublisher.publishEvent(new OrderCompletedEvent(orderId));
    }
}

// 开票模块监听事件,写入中间表
@Component
public class InvoiceSyncListener {
    
    @EventListener
    @Async
    public void handleOrderCompleted(OrderCompletedEvent event) {
        // 1. 查询订单详情
        Order order = orderRepository.findForInvoice(event.getOrderId());
        
        // 2. 写入中间表(使用 INSERT ... ON DUPLICATE KEY UPDATE 防止重复消费)
        OrderInvoicePending pending = new OrderInvoicePending();
        pending.setOrderId(order.getId());
        pending.setUserId(order.getUserId());
        pending.setOrderAmount(order.getAmount());
        pending.setOrderCreateTime(order.getCreateTime());
        pending.setOrderCompleteTime(order.getCompleteTime());
        pending.setPendingStatus(PendingStatus.PENDING);
        pendingRepository.saveOrUpdate(pending);
    }
}

写入时的 SQL:

INSERT INTO order_invoice_pending (order_id, user_id, order_amount, order_create_time, order_complete_time, pending_status, sync_time)
VALUES (#{orderId}, #{userId}, #{amount}, #{createTime}, #{completeTime}, 0, NOW())
ON DUPLICATE KEY UPDATE
    order_amount = VALUES(order_amount),
    order_complete_time = VALUES(order_complete_time),
    pending_status = 0,
    sync_time = NOW();

五、开票业务流程

5.1 状态流转图

┌─────────────┐      用户申请开票      ┌─────────────┐
│  待开票      │ ──────────────────→  │  已开票      │
│  (0)        │                      │  (1)        │
└─────────────┘                      └─────────────┘
       ↑                                     │
       │                                     │ 开票成功
       │                                     │ (终态,不再变化)
       │        开票失败回滚                   ↓
       │         (重置状态)             ┌─────────────┐
       └────────────────────────────── │  开票成功    │
            用户重新申请开票              │  (主表状态)  │
                                       └─────────────┘

状态说明

状态值 含义 说明
0 待开票 订单已完成,可被用户勾选申请开票
1 已开票 用户已提交开票申请,等待或已完成开票
2 已取消 订单不可开票(如已退款、已作废等)

5.2 完整业务流程

1. 用户进入开票页面
   → 查询中间表:
     SELECT * FROM order_invoice_pending 
     WHERE user_id = ? AND pending_status = 0
   → 展示待开票订单列表(完全不触碰订单表)

2. 用户勾选订单,提交开票申请
   → 双重防护:
     ① Redis分布式锁(用户维度,防止重复提交)
     ② 数据库状态乐观锁(pending_status = 0 → 1)
   → 更新中间表状态为"已开票",记录 invoice_id
   → 插入开票主表(invoice_master)状态为"处理中"
   → 插入开票订单明细表(invoice_order_detail)
   → 异步调用第三方发票服务

3. 开票成功
   → 更新 invoice_master.status = 1(成功)
   → (中间表状态已在步骤2更新,无需再操作)

4. 开票失败
   → 更新 invoice_master.status = 2(失败),记录失败原因
   → 回滚中间表:pending_status = 0(待开票),清空 invoice_id
   → 用户可在页面重新看到该订单,可再次发起开票

5. 查询开票历史
   → 查询 invoice_master + invoice_order_detail

5.3 开票申请,采用双重防护机制

开票申请涉及金额,必须确保并发安全。采用两层防护

第一层:Redis分布式锁(用户维度) ,防止同一用户快速点击多次提交,减轻数据库压力。

第二层:数据库乐观锁(pending_status 条件更新) ,即使Redis锁因某种原因失效(如锁超时),数据库层的状态条件更新仍然能保证数据一致性。

完整代码实现

@Service
@Slf4j
public class InvoiceApplyService {

    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private OrderInvoicePendingRepository pendingRepository;
    
    @Autowired
    private InvoiceMasterRepository masterRepository;
    
    @Autowired
    private InvoiceOrderDetailRepository detailRepository;

    @Transactional
    public String applyInvoice(InvoiceApplyRequest request) {
        String userId = request.getUserId();
        List<String> orderIds = request.getOrderIds();
        String invoiceId = generateInvoiceId();
        
        // ========== 第一层防护:Redis分布式锁 ==========
        String lockKey = "invoice:apply:" + userId;
        RLock lock = redissonClient.getLock(lockKey);
        boolean locked = false;
        try {
            locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
            if (!locked) {
                throw new BusinessException("操作太频繁,请稍后重试");
            }
            
            // ========== 第二层防护:数据库乐观锁 ==========
            // 批量更新中间表状态:只有 pending_status = 0 的订单才会被更新
            int updated = pendingRepository.updateStatusByOrderIds(
                orderIds, 
                invoiceId, 
                PendingStatus.INVOICED
            );
            // 对应的SQL:
            // UPDATE order_invoice_pending
            // SET pending_status = 1, invoice_id = #{invoiceId}
            // WHERE order_id IN (...) AND pending_status = 0
            
            if (updated != orderIds.size()) {
                // 部分或全部订单已被其他请求处理
                throw new ConcurrentModificationException(
                    "部分订单已被其他开票申请锁定,请刷新后重试"
                );
            }
            
            // ========== 创建开票记录 ==========
            // 构建开票主表
            InvoiceMaster master = buildInvoiceMaster(userId, invoiceId, orderIds);
            masterRepository.save(master);
            
            // 构建开票明细
            List<InvoiceOrderDetail> details = buildDetails(invoiceId, orderIds);
            detailRepository.saveAll(details);
            
            // 异步提交开票
            invoiceSubmitService.submitAsync(invoiceId);
            
            return invoiceId;
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("系统繁忙,请稍后重试");
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

两层防护的协作关系

防护层 作用 解决什么问题
Redis分布式锁(用户维度) 同一用户串行化处理 防止用户快速重复点击、减轻DB压力
数据库乐观锁(状态条件) 保证更新的原子性 防止跨用户/跨实例的并发冲突,作为最终防线

六、开票失败与重新申请

6.1 开票失败回滚

当第三方开票服务返回失败时,需要回滚中间表状态,让订单回到“待开票”状态,供用户重新申请:

@Service
@Slf4j
public class InvoiceCallbackService {

    @Transactional
    public void handleInvoiceFailure(String invoiceId, String failReason) {
        // 1. 查询开票主表
        InvoiceMaster master = masterRepository.findById(invoiceId);
        Assert.notNull(master, "开票记录不存在");
        
        // 2. 更新开票主表状态为"失败"
        master.setStatus(InvoiceStatus.FAILED);
        master.setFinishTime(new Date());
        master.setFailReason(failReason);
        masterRepository.save(master);
        
        // 3. 回滚中间表状态:已开票(1) → 待开票(0)
        // 只有状态为"已开票"且invoice_id匹配的记录才会被回滚
        int updated = pendingRepository.resetStatusByInvoiceId(
            invoiceId, 
            PendingStatus.PENDING  // 回滚到"待开票"
        );
        // SQL:
        // UPDATE order_invoice_pending
        // SET pending_status = 0, invoice_id = NULL
        // WHERE invoice_id = #{invoiceId} AND pending_status = 1
        
        if (updated == 0) {
            log.warn("回滚中间表失败:invoice_id={}, 可能状态已变更", invoiceId);
        }
        
        // 4. 发送通知给用户,告知开票失败可重新申请
        notificationService.notifyInvoiceFailed(master.getUserId(), failReason);
    }
}

6.2 重新申请开票

开票失败后,用户无需任何额外操作,只需在开票页面再次勾选该订单并提交即可:

用户进入开票页面
   → 查询中间表:pending_status = 0(待开票)
   → 之前开票失败的订单已恢复为"待开票"状态
   → 用户可以再次勾选并提交
   → 走正常的开票申请流程(同5.3节)

关键设计点

  1. 幂等性保证:每次开票申请都生成全新的 invoice_id,不会与历史失败的开票记录混淆
  2. 数据追溯:失败的开票记录保留在 invoice_master 表中,status = 2 标识失败,可通过 fail_reason 字段查询失败原因
  3. 回滚条件:回滚时带 pending_status = 1 条件,避免回滚过程中被其他操作干扰

七、方案总结

维度 改造前(需求直译模式) 改造后(中间表模式)
订单表写操作 每次开票都要 UPDATE 零写入
订单表读操作 每次查询待开票列表都要 SELECT 零读取
业务耦合 开票逻辑侵入订单表 完全解耦,通过中间表隔离
性能影响 随订单量增长线性恶化 稳定,不受订单总量影响
并发安全 依赖订单表行锁 Redis锁 + 状态乐观锁双重防护
失败重试 回滚状态字段,耦合订单表 独立状态回滚,不影响订单表

核心设计原则

  1. 单向依赖:开票域依赖中间表,订单域不知道开票的存在,依赖方向清晰
  2. 异步解耦:通过事件驱动实现数据同步,订单主流程不受开票业务影响
  3. 双重防护:Redis分布式锁减轻数据库压力,数据库状态乐观锁作为最终防线,兼顾性能与安全
  4. 状态即锁pending_status 本身充当乐观锁条件,无需额外 version 字段,简洁且贴合业务语义
  5. 失败可恢复:开票失败后通过回滚中间表状态,让用户可重新发起,无需人工介入

一句话总结:通过引入“待开票订单中间表”,将开票业务对订单表的读写依赖转化为异步数据同步,实现了开票域与订单核心域的彻底解耦。

这种“中间表隔离”模式不仅适用于开票场景,还可以推广到任何需要从核心表剥离只读业务的场景——如账单生成、数据导出、报表统计等。核心思想是:核心表只服务于核心业务,下游业务通过数据冗余的方式独立运作。

posted on 2026-08-04 12:21  buguge  阅读(11)  评论(0)    收藏  举报