为什么应用层事务代替数据库外键保证一致性¶
🎯 核心思想¶
✅ 应用层用事务(而不是数据库外键)来实现数据的一致性保障,这样做可以同时:
| 优点 | 解释 |
|---|---|
| 提高插入性能 | 没有外键校验,数据库写入直接落盘,不用多余的查表动作。TPS 提升几十倍到上百倍。 |
| 支持分库分表 | 分库后(跨库),外键约束根本不能用,只有应用自己控制事务才能跨库一致。 |
| 防止连锁误删 | 删除父表不会自动 cascade 子表,只能应用逻辑控制,避免一删一片血案。 |
| 灵活扩展数据模型 | 表之间松耦合,想加新表、改字段,不用担心DDL动一动就影响一堆外键。 |
| 容易控制幂等和补偿 | 通过业务字段(比如 flow_id 唯一索引)来做幂等校验,补偿机制也好实现。 |
🔥 总结一句话¶
应用层用事务控制一致性,是高性能、高并发、大规模系统的标配做法,彻底摆脱外键的低效和局限。
📚 外键保证一致性 vs 应用事务保证一致性¶
| 外键保证一致性 | 应用事务保证一致性 |
|---|---|
| 插入时数据库自动查父表验证 | 应用程序先查父表,拿到需要的数据后手动插入子表 |
| 删除父表时 cascade 自动删子表 | 删除前应用先检查子表是否允许删或逻辑软删 |
| 跨库无法生效 | 跨库通过分布式事务(Seata/TCC/SAGA)控制 |
| 写入慢、锁多、吞吐低 | 写入快、事务小、系统抗压能力强 |
🛠 应用层事务示例(详细版)¶
比如 Spring Boot 中的写法:
@Transactional
public void doLottery(User user, Activity activity) {
// 1. 查询用户账户信息,验证合法性
UserTimesAccount account = userTimesAccountMapper.selectByUserId(user.getId());
if (account.getAvailableTimes() <= 0) {
throw new BusinessException("抽奖次数不足");
}
// 2. 扣减账户可用次数
account.setAvailableTimes(account.getAvailableTimes() - 1);
userTimesAccountMapper.updateById(account);
// 3. 插入抽奖流水记录
UserTimesFlow flow = new UserTimesFlow();
flow.setUserId(user.getId());
flow.setActivityId(activity.getId());
flow.setTimesChange(-1);
flow.setFlowId(generateUniqueFlowId());
flow.setFlowChannel("抽奖");
userTimesFlowMapper.insert(flow);
// 4. 扣减活动库存(如果有库存)
activity.setStock(activity.getStock() - 1);
activityMapper.updateById(activity);
}
✅ 整个扣次数、记流水、扣库存是包裹在一个本地事务里的。只要中间任意一步失败,整个操作就会回滚,不会出现扣了次数但没扣库存的问题。
如果这里用了数据库外键会怎样?¶
- 插入
user_times_flow流水时,数据库会去user_times_account账户表再次去查询 user_id 是否存在。 - 每次插入流水时,多一次隐藏的查询校验,TPS降低明显。
- 在高并发下(比如双11秒杀),外键校验导致数据库锁表,查询堵塞,吞吐量下降。
- 如果后期把账户表和流水表拆成不同库,外键彻底失效,必须强制去掉,增加迁移风险。
🗂️ 两种数据表设计模式对比¶
| 设计方式 | 特点 | 适用场景 |
|---|---|---|
| 强外键模式(建外键) | 自动校验完整性,插入写入慢,灵活性差 | 小型项目、数据量小、单库系统 |
| 应用事务控制模式(无外键) | 逻辑控制,扩展灵活,写入快,支持分库分表 | 中大型项目、高并发、微服务架构 |
✅ 结论:大规模、高并发系统必须采用应用事务控制,无外键设计。
✨ 扩展版总结¶
正确的做法就是:
- 外键不建,表靠业务逻辑联系;
- 事务控制一批数据的一致性;
- 高性能插入,高并发扩展,系统结构清晰;
- 便于后期水平扩展(分库分表、异地多活)。