# 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` 可能成为持锁来源，排查时重点关注。
