文章目录
- 每日一句正能量
- 前言
- 1. 背景与问题
- 2. 环境与数据
- 3. 复现过程
- 3.1 写一个“功能很完整”的复杂触发器
- 3.2 第一个问题:锁范围扩大
- 3.3 第二个问题:锁顺序不一致
- 3.4 第三个问题:应用日志看不到隐式工作
- 4. 方案实施
- 4.1 先做性能测试,而不是凭感觉争论
- 4.2 应用层显式事务替代
- 4.3 JDBC 版本
- 4.4 MyBatis 适配
- 4.5 JPA/Hibernate 适配
- 4.6 复杂逻辑不一定都要同步
- 4.7 Outbox 替代跨服务触发逻辑
- 4.8 统计数据适合异步刷新
- 4.9 事务边界:触发器会延长主事务
- 4.10 触发器异常会直接打断主 SQL
- 4.11 复杂触发器与重试的关系
- 4.12 多层触发器尤其危险
- 4.13 哪些逻辑适合继续保留在触发器
- 4.14 边界测试
- 5. 结果对比
- 实施前:复杂触发器
- 实施后:显式事务 + 异步拆分
- 6. 风险与复盘
- 6.1 把逻辑搬出触发器不等于全部搬到一个 Service
- 6.2 应用层也要保证原子性
- 6.3 Outbox 不是强一致
- 6.4 历史系统迁移要分阶段
- 6.5 触发器也必须进入版本管理
- 6.6 最重要的是保持“执行路径可见”
- 结语
每日一句正能量
淬炼与证明
撑不住的时候,正是成长的时候。
人的能力边界,正是在旧有模式“撑不住”时,被强行拓展的。最黑暗的时刻,往往离黎明最近。
前言
触发器最大的优点,是“只要数据发生变化,规则就一定执行”。
这也是它最大的风险。
在简单审计字段场景里,BEFORE UPDATE自动维护updated_at、updated_by非常自然;但当触发器开始承担库存联动、金额汇总、状态推进、历史表写入、跨表校验甚至多层触发时,数据库就会出现一种非常难排查的现象:
应用只执行了一条 SQL, 数据库实际上做了十几件事。高并发系统最怕的不是单次逻辑复杂,而是复杂逻辑被隐藏在每一次 DML 后面,自动扩大锁范围、延长事务时间,并且很难从应用日志直观看出原因。
因此,触发器治理的关键不是“能不能写”,而是:
哪些逻辑值得放进触发器, 哪些逻辑必须搬出来。本文通过一个订单状态更新触发器,复现复杂触发器在高并发下的性能问题,再给出应用事务、Outbox、异步统计等替代方案,并说明 JDBC、MyBatis、JPA/Hibernate 调用时的异常和事务边界。
1. 背景与问题
假设订单系统原本只有一条更新:
UPDATEordersSETstatus='PAID'WHEREorder_no=?;为了“让逻辑统一”,团队逐渐把下面这些动作全部塞进触发器:
更新支付汇总表 扣减库存 插入操作历史 更新用户累计消费 刷新日报统计结果应用代码非常干净:
orderMapper.markPaid(orderNo);但数据库真实执行链变成:
UPDATE orders -> trigger -> SELECT inventory -> UPDATE inventory -> INSERT order_history -> UPDATE user_stat -> UPDATE daily_stat这类设计在低并发时往往表现良好,因为开发人员只看到:
一条 UPDATE 就完成所有事情。一旦并发升高,问题开始出现:
P99 延迟突然拉长 锁等待增加 死锁变多 批量更新速度下降 数据库 CPU 升高 应用侧无法快速定位2. 环境与数据
示例环境:
JDK 21 Spring Boot 3.3+ MySQL 8.0+ HikariCP Spring JDBC MyBatis 3.x Hibernate 6 / JPA订单表:
CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULLUNIQUE,user_idBIGINTNOTNULL,sku_idBIGINTNOTNULL,quantityINTNOTNULL,amountDECIMAL(18,2)NOTNULL,statusVARCHAR(32)NOTNULL,updated_atTIMESTAMP(6)NOTNULL);库存表:
CREATETABLEinventory(sku_idBIGINTPRIMARYKEY,stockINTNOTNULL,updated_atTIMESTAMP(6)NOTNULL);用户统计:
CREATETABLEuser_stat(user_idBIGINTPRIMARYKEY,paid_amountDECIMAL(18,2)NOTNULL,order_countBIGINTNOTNULL);订单历史:
CREATETABLEorder_history(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULL,old_statusVARCHAR(32),new_statusVARCHAR(32),created_atTIMESTAMP(6)NOTNULL);3. 复现过程
3.1 写一个“功能很完整”的复杂触发器
MySQL 示例:
DELIMITER$$CREATETRIGGERtrg_orders_after_updateAFTERUPDATEONordersFOR EACH ROWBEGINIFOLD.status<>NEW.statusANDNEW.status='PAID'THENUPDATEinventorySETstock=stock-NEW.quantity,updated_at=CURRENT_TIMESTAMP(6)WHEREsku_id=NEW.sku_idANDstock>=NEW.quantity;INSERTINTOorder_history(order_no,old_status,new_status,created_at)VALUES(NEW.order_no,OLD.status,NEW.status,CURRENT_TIMESTAMP(6));INSERTINTOuser_stat(user_id,paid_amount,order_count)VALUES(NEW.user_id,NEW.amount,1)ONDUPLICATEKEYUPDATEpaid_amount=paid_amount+NEW.amount,order_count=order_count+1;ENDIF;END$$DELIMITER;功能看起来很漂亮。
应用只需要:
UPDATEordersSETstatus='PAID'WHEREorder_no=?;就能联动三张表。
问题是,这些动作全部加入当前事务。
3.2 第一个问题:锁范围扩大
如果两个订单属于同一个 SKU 和同一个用户:
订单 A 更新 orders_A 订单 B 更新 orders_B看起来它们不是同一行。
但触发器内部又会竞争:
inventory(sku_id) user_stat(user_id)于是原本没有冲突的两个订单事务,突然在触发器内部产生锁竞争。
3.3 第二个问题:锁顺序不一致
假设另一条业务路径是:
先 UPDATE user_stat 再 UPDATE orders而订单触发器是:
先 orders 再 inventory 再 user_stat两个事务很容易形成:
事务 A: orders -> user_stat 事务 B: user_stat -> orders死锁风险立刻上升。
3.4 第三个问题:应用日志看不到隐式工作
应用 SQL 日志可能只有:
UPDATE orders SET status=? WHERE order_no=? duration=320ms开发人员会怀疑:
orders 索引是不是有问题?但真正慢的是触发器里的:
UPDATE inventory UPDATE user_stat如果 SQL 可观测系统没有展开触发器内部执行,定位会很慢。
4. 方案实施
4.1 先做性能测试,而不是凭感觉争论
测试代码:
@TestvoidconcurrentMarkPaid()throwsException{intthreads=50;intrequests=5000;ExecutorServicepool=Executors.newFixedThreadPool(threads);CountDownLatchdone=newCountDownLatch(requests);LongAddersuccess=newLongAdder();LongAdderfailed=newLongAdder();longstart=System.nanoTime();for(inti=0;i<requests;i++){longid=i+1;pool.submit(()->{try{orderService.markPaid(id);success.increment();}catch(Exceptione){failed.increment();}finally{done.countDown();}});}done.await();longelapsedMs=TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-start);System.out.printf("success=%d, failed=%d, elapsed=%dms%n",success.sum(),failed.sum(),elapsedMs);}测试至少比较两组:
A:复杂触发器 B:应用层显式事务并观察:
吞吐量 P95/P99 锁等待 死锁次数 数据库 CPU 事务平均时长4.2 应用层显式事务替代
把触发器逻辑搬到应用事务:
@TransactionalpublicvoidmarkPaid(longorderId){Orderorder=orderRepository.findForUpdate(orderId);if("PAID".equals(order.status())){return;}orderRepository.markPaid(orderId);introws=inventoryRepository.deduct(order.skuId(),order.quantity());if(rows!=1){thrownewOutOfStockException();}historyRepository.insert(order.orderNo(),order.status(),"PAID");userStatRepository.addPaid(order.userId(),order.amount());}看起来代码变多了。
但好处非常直接:
调用顺序显式 异常路径显式 SQL 日志显式 事务边界显式高并发系统里,“显式”本身就是可维护性。
4.3 JDBC 版本
publicvoidmarkPaid(longorderId)throwsSQLException{try(Connectionc=dataSource.getConnection()){c.setAutoCommit(false);try{OrderRoworder=selectOrderForUpdate(c,orderId);updateOrder(c,orderId);deductInventory(c,order.skuId(),order.quantity());insertHistory(c,order);updateUserStat(c,order);c.commit();}catch(Exceptione){c.rollback();throwe;}}}这样数据库行为与 Java 调用链完全对应。
4.4 MyBatis 适配
Mapper 分拆:
<updateid="markPaid">UPDATE orders SET status = 'PAID', updated_at = NOW(6) WHERE id = #{id} AND status <> 'PAID'</update>库存:
<updateid="deductStock">UPDATE inventory SET stock = stock - #{quantity} WHERE sku_id = #{skuId} AND stock >= #{quantity}</update>Service:
@TransactionalpublicvoidmarkPaid(longid){Orderorder=orderMapper.findForUpdate(id);if(order==null){thrownewOrderNotFoundException();}orderMapper.markPaid(id);introws=inventoryMapper.deductStock(order.getSkuId(),order.getQuantity());if(rows!=1){thrownewOutOfStockException();}historyMapper.insert(...);userStatMapper.increase(...);}如果某一步失败:
整个事务回滚。4.5 JPA/Hibernate 适配
JPA:
@Lock(LockModeType.PESSIMISTIC_WRITE)@Query(""" select o from OrderEntity o where o.id = :id """)Optional<OrderEntity>findForUpdate(@Param("id")Longid);业务:
@TransactionalpublicvoidmarkPaid(longid){OrderEntityorder=repository.findForUpdate(id).orElseThrow();order.markPaid();inventoryService.deduct(order.getSkuId(),order.getQuantity());historyRepository.save(OrderHistoryEntity.from(order));userStatService.increase(order.getUserId(),order.getAmount());}ORM 项目最大的收益是:
实体行为和事务逻辑可以一起测试。而不是把关键逻辑藏在数据库触发器里。
4.6 复杂逻辑不一定都要同步
用户累计消费:
paid_amount order_count如果它只是展示用途,不要求和订单状态强一致,就没有必要放进主事务。
可以改成:
订单事务 -> 写订单 -> 写 Outbox 事件 -> 提交 异步消费者 -> 更新 user_stat这样主事务少锁一张热点统计表。
4.7 Outbox 替代跨服务触发逻辑
触发器根本不应该承担:
通知库存服务 调用积分服务 发送 MQ更合理:
@TransactionalpublicvoidmarkPaid(...){orderRepository.markPaid(...);outboxRepository.insert(OrderPaidEvent(...));}事务外:
Outbox publisher -> MQ -> 下游消费复杂跨服务流程由最终一致性处理,而不是试图让数据库触发器承担。
4.8 统计数据适合异步刷新
日报统计:
daily_stat如果每笔订单都同步 UPDATE:
同一天所有订单 -> 竞争同一统计行这会形成超级热点。
更合理的是:
事件流 批量聚合 定时刷新例如每 5 秒批量:
UPDATEdaily_statSETpaid_amount=paid_amount+?WHEREstat_date=?;大幅减少写竞争。
4.9 事务边界:触发器会延长主事务
必须牢记:
触发器执行时间 = 主 SQL 执行时间的一部分 = 当前事务持锁时间的一部分触发器并不会“后台慢慢执行”。
因此触发器里每多一次查询和更新,都可能让:
锁持有时间进一步延长。
4.10 触发器异常会直接打断主 SQL
例如:
SIGNAL SQLSTATE'45000'SETMESSAGE_TEXT='inventory update failed';应用看到的可能只是:
UPDATE orders failed如果没有把触发器异常分类好,很难知道真正失败点。
Spring 可能最终包装为:
DataIntegrityViolationException DataAccessException所以触发器里不要用模糊异常文本。
4.11 复杂触发器与重试的关系
高并发下死锁增加后,应用往往会加:
自动重试但如果触发器逻辑本身锁顺序不合理:
重试只是在重复撞死锁。应该先修:
锁顺序 事务长度 热点设计再谈重试。
4.12 多层触发器尤其危险
例如:
orders trigger -> UPDATE inventory inventory trigger -> INSERT inventory_history inventory_history trigger -> UPDATE daily_stat应用只执行:
UPDATE orders数据库却发生三层隐式联动。
这种结构非常难进行:
影响分析 性能评估 版本回滚 故障定位生产系统应尽量避免触发器链。
4.13 哪些逻辑适合继续保留在触发器
触发器不是不能用。
适合:
updated_at updated_by 简单审计字段 轻量历史记录 简单数据约束兜底共同特点:
单行 轻量 确定 无外部依赖 无复杂查询不适合:
跨多表复杂业务 热点统计 跨服务调用 消息发送 复杂状态机 大范围聚合4.14 边界测试
测试不能只看业务结果。
要测:
触发器开启 / 关闭吞吐差异 并发 10 / 50 / 100 相同 SKU 热点 相同 user_id 热点 死锁次数 事务平均耗时一个有价值的测试结果可能是:
简单 UPDATE: P99 18ms 复杂触发器: P99 190ms 应用显式事务: P99 72ms这里数字只是示例,真实项目必须自己压测。
关键是用数据决定:
复杂触发器是否值得保留。5. 结果对比
实施前:复杂触发器
应用:
一条 UPDATE数据库:
订单 库存 历史 用户统计 日报统计全部同步执行。
优点:
调用简单 规则集中缺点:
锁范围大 事务长 可观测性差 死锁复杂 难以灰度实施后:显式事务 + 异步拆分
同步事务只保留:
订单状态 核心库存 必要历史异步处理:
用户统计 日报 通知 跨服务动作结果:
主事务更短 锁更少 SQL 链路清晰 异常更容易分类 可独立扩容高并发系统更容易稳定。
6. 风险与复盘
6.1 把逻辑搬出触发器不等于全部搬到一个 Service
如果只是从:
复杂 trigger搬成:
一个 1000 行 @Transactional 方法问题只是换了位置。
仍然需要拆:
核心同步事务 异步衍生逻辑 最终一致性流程6.2 应用层也要保证原子性
触发器搬出后,如果:
订单更新成功 库存扣减失败必须由同一个本地事务回滚。
不能为了“去触发器”反而破坏一致性。
6.3 Outbox 不是强一致
异步方案会产生短暂不一致。
因此只有:
允许最终一致的统计、通知、跨服务动作才适合异步化。
核心库存是否能异步,要看业务 SLA。
6.4 历史系统迁移要分阶段
复杂触发器如果已经运行多年,不建议一次性删除。
可以:
第一阶段:补可观测 第二阶段:压测 第三阶段:双写比对 第四阶段:关闭部分逻辑 第五阶段:彻底移除降低迁移风险。
6.5 触发器也必须进入版本管理
所有触发器 DDL 都应该:
Git 管理 Flyway/Liquibase 发布 有回滚脚本不能依赖 DBA 手工修改。
6.6 最重要的是保持“执行路径可见”
高并发系统里,性能问题往往不是某一条 SQL 本身,而是:
一次业务请求究竟隐式触发了多少数据库工作。执行路径越显式,越容易:
观测 压测 限流 优化 回滚结语
复杂触发器之所以需要谨慎,不是因为触发器本身“落后”,而是因为它天然具有:
自动执行 隐式执行 跟随主事务执行三个特征。
当逻辑很轻时,这三个特征是优势。
当逻辑变复杂时,它们会变成:
隐式锁 隐式延迟 隐式副作用可以用一句工程原则总结:
轻量、确定、单行相关的逻辑可以留在触发器; 复杂、跨表、跨服务、热点明显的逻辑应该显式化。高并发系统真正需要的不是“把代码放在哪里最省事”,而是让每一次数据库工作都能被看见、被测试、被度量,并且拥有清晰的异常与事务边界。
转载自:https://blog.csdn.net/u014727709/article/details/165243097
欢迎 👍点赞✍评论⭐收藏,欢迎指正