qmj пре 1 месец
родитељ
комит
d6487e7ddc
2 измењених фајлова са 141 додато и 0 уклоњено
  1. 0 0
      .claude/homunculus/observations.jsonl
  2. 141 0
      docs/issue-pos-order-lock-timeout.md

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
.claude/homunculus/observations.jsonl


+ 141 - 0
docs/issue-pos-order-lock-timeout.md

@@ -0,0 +1,141 @@
+# pos_order 行锁等待超时(Lock wait timeout exceeded)排查记录
+
+- **日期**: 2026-07-23 16:32
+- **环境**: 测试环境(test 分支,端口 8082)
+- **接口**: `GET /system/orderShOprate/completeOrder`(商家完成订单:state 2→3,payStatus 0→1)
+- **报错位置**: `PosOrderShOprateController.java:305`
+- **涉及表**: `pos_order`
+
+---
+
+## 一、报错信息(原文)
+
+```
+16:32:02.519 [http-nio-8082-exec-86] ERROR c.r.f.w.e.GlobalExceptionHandler - [handleRuntimeException,69] -
+请求地址'/system/orderShOprate/completeOrder',发生未知异常.
+
+org.springframework.dao.CannotAcquireLockException:
+### Error updating database.  Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
+Lock wait timeout exceeded; try restarting transaction
+### The error may exist in com/ruoyi/system/mapper/PosOrderMapper.java (best guess)
+### The error may involve com.ruoyi.system.mapper.PosOrderMapper.updateById-Inline
+### The error occurred while setting parameters
+### SQL: UPDATE pos_order SET state=?, pay_status=? WHERE id=?
+### Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
+Lock wait timeout exceeded; try restarting transaction
+; Lock wait timeout exceeded; try restarting transaction
+    ...
+    at com.baomidou.mybatisplus.extension.repository.AbstractRepository.saveOrUpdate(AbstractRepository.java:73)
+    ...
+    at com.ruoyi.system.service.impl.PosOrderServiceImpl$$SpringCGLIB$$0.saveOrUpdate(<generated>)
+    at com.ruoyi.app.order.PosOrderShOprateController.completeOrder(PosOrderShOprateController.java:305)
+    ...
+Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
+Lock wait timeout exceeded; try restarting transaction
+```
+
+(上方省略部分为 Tomcat / Spring Security 过滤器链与 MyBatis-Plus 反射代理帧,与根因无关。)
+
+---
+
+## 二、结论:这个报错是什么意思
+
+**核心:MySQL 行锁等待超时(错误码 1205,`Lock wait timeout exceeded`)。**
+
+不是 SQL 写错,也不是数据问题,而是:
+
+> 这条 `UPDATE pos_order ... WHERE id=?` 想给某一行加排他锁(X 锁),但这一行的锁已经被另一个事务占着不放,当前事务等了超过 `innodb_lock_wait_timeout`(默认 50 秒)还没拿到,于是 MySQL 主动放弃并回滚这一条语句。
+
+### 与死锁的区别
+
+| 错误 | 含义 | 触发方式 |
+|------|------|---------|
+| **1205 Lock wait timeout**(本次) | 别人占锁太久,我等不下去 | 等 ≥50s 后超时 |
+| 1213 Deadlock | 互相等对方,死锁 | MySQL 检测到后**立刻**回滚一方 |
+
+→ 本次是"**另一个事务长时间没提交/回滚,把这一行锁住了**",**不是死锁**。
+
+---
+
+## 三、发生点代码
+
+`PosOrderShOprateController#completeOrder`(第 292–313 行):
+
+```java
+@Transactional(rollbackFor = Exception.class)   // 整个方法在一个事务里
+@GetMapping("/completeOrder")
+public AjaxResult completeOrder(@RequestHeader String token, @RequestParam Long id) {
+    PosOrder order = posOrderService.getOne(...);
+    // 状态/类型校验 ...
+    PosOrder update = new PosOrder();
+    update.setId(order.getId());
+    update.setState(3L);
+    update.setPayStatus(1L);
+    posOrderService.saveOrUpdate(update);        // ← 305 行:拿 pos_order 行锁超时(受害者)
+    ...
+    orderLogHelper.log(...);
+    PosOrder fullOrder = posOrderService.getById(order.getId());
+    orderService.setSanghuBilling(fullOrder);   // 事务尚未提交,锁继续持有到方法结束
+    return AjaxResult.success();
+}
+```
+
+**当前 `completeOrder` 是"被阻塞、最终超时"的受害者**。真正的"凶手"是另一个占着同一行 `pos_order` 锁超过 50 秒的事务。
+
+---
+
+## 四、凶手最可能是谁(按概率排序)
+
+1. **某个卡死 / 未提交的事务长期持锁**(测试环境最常见)
+   - 之前有个请求(如支付回调 `PayController.payipn`、`NewebpayPayController`、或另一次订单操作)也在 `@Transactional` 里改这一行,但中间卡住了(HTTP 推送超时、远程调用 hang 住、连接泄漏),事务一直没结束 → 锁不释放。
+   - 或有人在 SQL 客户端里 `BEGIN; UPDATE pos_order ...` 后忘了 `COMMIT`,把行锁攥在手里。
+
+2. **同一订单的并发操作撞车**
+   - 前端重复点击"完成",或同一订单同时有"完成 + 取消 + 支付回调"在跑,几个事务抢同一行的锁。一般几秒内解决,不至于 50 秒;若超过 50 秒,多半叠加了原因 1。
+
+3. **长事务包裹了慢操作**(系统性根因)
+   - `completeOrder` 在 `@Transactional` 内、update 后又调 `setSanghuBilling(...)` 生成账单。**锁从 305 行 update 一直持有到方法结束才提交**。若 `setSanghuBilling` 很慢(多表查询/计算),这个事务持锁就很久,反过来会成为"下一个并发请求"的凶手。
+   - `@Transactional` 里混入慢/外部操作是这类问题的系统性根因。
+
+---
+
+## 五、如何定位真正的凶手
+
+复现或刚好发生时,在 MySQL 里按顺序执行:
+
+```sql
+-- 1. 看当前所有活跃事务,重点看 trx_started 跑了多久、trx_state 是否 LOCK WAIT
+SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
+FROM information_schema.INNODB_TRX;
+
+-- 2. 看谁在等锁、谁堵谁(blocker vs waiter)
+SELECT * FROM sys.innodb_lock_waits;
+
+-- 3. MySQL 8:看 pos_order 上的锁持有者
+SELECT * FROM performance_schema.data_locks
+WHERE OBJECT_NAME = 'pos_order';
+
+-- 4. 最近一次锁等待/死锁诊断
+SHOW ENGINE INNODB STATUS\G   -- 看 TRANSACTIONS 段
+```
+
+判断要点:`INNODB_TRX` 里 `trx_started` 远早于现在、且 `trx_query` 为空的那个事务,基本就是占着锁不走的凶手。记下它的 `trx_mysql_thread_id`,可 `KILL <id>` 强制回滚释放锁(治标),再排查对应业务代码为何没提交(治本)。
+
+---
+
+## 六、处理方案
+
+### 临时处理
+找到持锁事务后 `KILL <trx_mysql_thread_id>`,订单即可正常完成。
+
+### 长期改善(待实施)
+- [ ] 把慢操作(推送、账单计算、外部 HTTP 调用)移出 `@Transactional`,或用 `TransactionTemplate` 只包住真正需要的 DB 写入,缩短持锁时间。
+- [ ] 订单状态流转加幂等 / 状态机锁:`UPDATE ... WHERE id=? AND state=2`(乐观条件更新),避免并发撞车。
+- [ ] 排查支付回调链路(`PayController.payipn` / `NewebpayPayController`)是否存在长事务持锁。
+
+---
+
+## 七、关联
+
+- CLAUDE.md「已废弃代码清单」:在用的订单操作入口为 `PosOrderShOprateController`(商家)/ `PosOrderQsOprateController`(骑手)/ `UserOrderController`(用户)/ `NewebpayPayController`(支付)。
+- 支付链路复用 `PayController.sendAcceptRiderPush`,支付回调若在同一事务内更新 `pos_order` 可能成为持锁来源,排查时重点关注。

Неке датотеке нису приказане због велике количине промена