业务解耦的经典实践:从订单表解耦开票业务说起(以滴滴网约车开票为例)
在千万级订单量的系统中,如何优雅地将开票业务从订单核心表中剥离
在滴滴网约车业务中,日订单量超过1千万。
滴滴网约车订单的开发票页面,见下方移动端页面截图。业务操作大家应该都知道,勾选一笔或多笔订单,点击“下一步”进行开发票。
如果按最直观的程序设计方式实现开票需求,流程如下:
- 在订单表上增加一个“是否开票”字段
- 开票页面查询“是否开票=false”的订单
- 批量开票后,更新订单表的“是否开票=true”
- 开票失败时,再将字段重置为false
- 开票历史从开票主表和订单明细表查询
一、这种“需求直译”模式存在两个致命问题
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节)
关键设计点:
- 幂等性保证:每次开票申请都生成全新的
invoice_id,不会与历史失败的开票记录混淆 - 数据追溯:失败的开票记录保留在
invoice_master表中,status = 2标识失败,可通过fail_reason字段查询失败原因 - 回滚条件:回滚时带
pending_status = 1条件,避免回滚过程中被其他操作干扰
七、方案总结
| 维度 | 改造前(需求直译模式) | 改造后(中间表模式) |
|---|---|---|
| 订单表写操作 | 每次开票都要 UPDATE | 零写入 |
| 订单表读操作 | 每次查询待开票列表都要 SELECT | 零读取 |
| 业务耦合 | 开票逻辑侵入订单表 | 完全解耦,通过中间表隔离 |
| 性能影响 | 随订单量增长线性恶化 | 稳定,不受订单总量影响 |
| 并发安全 | 依赖订单表行锁 | Redis锁 + 状态乐观锁双重防护 |
| 失败重试 | 回滚状态字段,耦合订单表 | 独立状态回滚,不影响订单表 |
核心设计原则:
- 单向依赖:开票域依赖中间表,订单域不知道开票的存在,依赖方向清晰
- 异步解耦:通过事件驱动实现数据同步,订单主流程不受开票业务影响
- 双重防护:Redis分布式锁减轻数据库压力,数据库状态乐观锁作为最终防线,兼顾性能与安全
- 状态即锁:
pending_status本身充当乐观锁条件,无需额外version字段,简洁且贴合业务语义 - 失败可恢复:开票失败后通过回滚中间表状态,让用户可重新发起,无需人工介入
一句话总结:通过引入“待开票订单中间表”,将开票业务对订单表的读写依赖转化为异步数据同步,实现了开票域与订单核心域的彻底解耦。
这种“中间表隔离”模式不仅适用于开票场景,还可以推广到任何需要从核心表剥离只读业务的场景——如账单生成、数据导出、报表统计等。核心思想是:核心表只服务于核心业务,下游业务通过数据冗余的方式独立运作。
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/22204510
问题即答案;推敲见文章。------ 问题:耦合;答案:解耦。
浙公网安备 33010602011771号