一、并发串行锁表
背景
我们的业务是禁用@Transactional的。但是有个古老的service接口使用了事务注解,我在第一句加了一个简单的updateByUserId语句更新一个user级别的表,结果产线监控发现这个小表的update sql执行了18s,那么为什么一句简单的sql会超时呢?
1、其他耗时步骤?
分析代码,这个service接口并没有什么比较耗时的操作,相对耗时的除了update db,还有一个http call,难道是http call超时了?
SQL 执行本身不会慢
事务内耗时操作(HTTP / sleep / 计算)会延长锁持有时间
其他事务访问同一行就可能 SQL 超时
2、并发量大?
日志显示,这次的http call本身也不耗时只有0.0几秒。我们根据超时的这次userId+业务关键字进一步搜索日志发现同一时刻这个事务service接口调用量有100次,也就是说100个事务同时抢占写锁,最后一个事务拿到写锁时一定是其他的99个事务执行完了,所以最后一个事务的执行时长是100个事务的总时长。从日志也能看出来,这个sql耗时一个比一个长。
1、问题抛出
@Transactional
public void service() {
updateDb(); // SQL
... // 省略逻辑(可能是 sleep、HTTP、其他逻辑)
}
1.1、"SQL 执行成功"到底指什么
这里有个误区:updateDb() 执行完了 → 数据已经写入数据库,但不完全正确:
-
数据确实被数据库记录在 undo/redo log 里
-
但外部事务看不到,锁也仍然占着
-
如果事务回滚,数据会消失
1.2、过程
当 updateDb() 执行时:
-
SQL 已经发送到数据库
-
数据库已经执行了修改(UPDATE 已经生效在 当前事务里)
-
事务还没有提交
所以:
-
从数据库内部来看,这条 SQL 已经"执行成功"
-
但是对外部事务(其他事务)不可见,对其他会话/连接的查询、更新仍然被锁阻塞
2、事务对sql的影响
2.1、省略号里的逻辑是否影响 SQL 成功?
| 省略号逻辑类型 | 对 updateDb() 的执行影响 |
|---|---|
| sleep / HTTP call / 计算 | 不影响 SQL 的执行,SQL 已在数据库执行成功(事务内部) |
| 抛异常(RuntimeException) | 会触发 事务回滚 → SQL 的修改最终 不会生效 |
| 修改其他表 / SQL | 不影响已经执行的 updateDb() 本身,但会延长锁持有时间 |
2.2、省略号里的逻辑是否影响 SQL 耗时?
@Transactional
public void service() {
updateDb(); // 10ms
Thread.sleep(7s); // 7s
callRemoteApi(); // 3s
}
sql本身的执行时间是不受影响的,是否超时要看db锁的情况,如果事务A在执行,事务B同时需要访问db的同一行,我们来看下A、B的耗时:
✅ 关键结论:
事务 A 自己的耗时是整个方法耗时(10 s),不会超时
事务 B 因为锁阻塞可能超时,取决于数据库 lock wait 设置
SQL 执行时间本身都是短的(10 ms),超时来自 锁等待
(1)事务A
T0 BEGIN TRANSACTION
T0~T0+10ms updateDb() 执行完成(事务内部成功)
T0+10ms~T10s Thread.sleep(7s) + callRemoteApi(3s)
T10s COMMIT
-
SQL 执行耗时:10 ms
-
事务总耗时(锁持有时间):约 10 s
✅ 事务 A 的sql本身不会超时,除非数据库对单事务有超长执行限制(很少见)。
(2)事务B
事务 B 同时尝试更新同一行:
T0 BEGIN TRANSACTION B
T0~T10s 等待事务 A 释放锁
T10s+ updateDb() 执行 10ms
T10s+ COMMIT
-
阻塞时间:约 10 s(等待锁)
-
SQL 执行时间:10 ms
-
事务总耗时:10 s + 10 ms ≈ 10 s
事务B的sql是否超时取决于 数据库锁等待超时时间 :如果你的数据库锁等待超时时间 小于 10 秒 ,事务 B 就会 SQLTimeoutException;否则不会。
| 数据库 | 默认锁等待超时 | 事务 B 阻塞是否超时? |
|---|---|---|
| MySQL InnoDB | 50 s (innodb_lock_wait_timeout) |
不会超时(10 s < 50 s) |
| PostgreSQL | 无限等待行锁(可设置 lock_timeout) |
不会超时,除非设置了 lock_timeout |
| Oracle | 无限等待(可用 NOWAIT 或 WAIT n) |
不会超时,除非设置了等待限制 |
3、sql超时的原因------并发和锁
回到最开始的问题,为什么会出现 SQL 超时,这就是经典的 "事务阻塞/锁导致 SQL 超时" 问题,事务里还有耗时操作 导致锁持有时间变长,影响并发 SQL。而不是 SQL 本身慢。核心原因:锁阻塞。
(1)事务延长
-
updateDb()执行成功(事务内部) -
但事务未提交 → 行锁/表锁仍在数据库里被持有
(2)其他 SQL 同步访问同一行
-
如果另一个事务/线程同时执行同一行的 update 或 select ... for update
-
它就会 等待锁释放
-
如果等待超过数据库的 lock wait 超时时间(MySQL 默认 50s,PostgreSQL 也是类似) → SQL 报 超时
(3)HTTP 调用影响
-
HTTP 调用耗时几秒甚至几十秒
-
事务一直没提交
-
其他事务就一直等待锁 → 超时
4、典型 SQL 超时示意
(1)事务 A
T0 updateDb() 执行成功
T0~T5 httpCall() 等待响应
T5 commit
(2)事务 B(同时更新同一行)
T0 update 同一行 → 等锁
T5+ 事务超时 → 报 SQLTimeoutException
5、分析监控
sql等待锁
SQL 等待锁 = SQL 已经到数据库了,但因为别人占着它需要的锁,只能停在那里等,对方提交或回滚。
事务 A:
@Transactional public void service() { update t set status = 1 where id = 1; // 拿到 id=1 的行锁 sleep / http call // 一直不 commit }事务 B(几乎同时):
update t set status = 2 where id = 1;1️⃣ A 先执行
A 执行
update id=1数据库做了两件事:
修改数据(事务内)
给 id=1 这行加了行锁
锁的意思是:
"这行现在我在改,别人不能动。"
2️⃣ B 执行 update(这一步就是"等待锁")
B 的 SQL 已经发到数据库
数据库发现:
- id=1 的行锁 被 A 持有
数据库不会报错,也不会直接执行
那什么时候不等了?
只有 3 种结果:
✅ 1. A 提交(commit)
锁释放
B 立刻执行 update(几 ms)
SQL 成功
❌ 2. A 回滚(rollback)
锁释放
B 执行 update
SQL 成功
💥 3. 等太久 → 超时
等待时间 > 数据库配置的锁等待超时
B 报错:
MySQL
Lock wait timeout exceeded; try restarting transactionJava
SQLTimeoutException
那么这个在监控里面会算作执行时长吗
要看"是哪一层的监控"。
| 监控位置 | 等锁算不算执行时长 |
|---|---|
| 应用层(JDBC / APM) | ✅ 算 |
| MyBatis / Hibernate 日志 | ✅ 算 |
| 慢 SQL 日志 | ❌ 通常不算 |
| DB 锁等待监控 | 单独统计 |
(1)应用层 / JDBC / APM(最常见)
如Spring 日志、JDBC Driver、SkyWalking / Pinpoint / NewRelic、MyBatis / Hibernate SQL 监控,中间发生了什么它不关心,它们的计时方式是:
start = 调用 executeUpdate()
end = executeUpdate() 返回
-
SQL 在等锁 10 秒
-
executeUpdate() 卡 10 秒
-
监控里看到:SQL 执行 10 秒
**(2)**数据库慢 SQL 日志(MySQL / PG / Oracle)
MySQL slow log
-
记录的是:
- SQL 真正执行(CPU / IO)
-
锁等待通常不算在内(或单独统计)
Query_time: 0.01
Lock_time: 9.99
(3)数据库锁监控 / 事务视图(最准)
information_schema.innodb_trx
performance_schema.data_lock_waits
6、去掉事务能变快吗?
如果并发更新的是db的同一行,那么即使去掉事务也不能彻底解决锁表的问题,顶多是减少了一点锁表的时间(减少了http call的总时长)。那么如何解决呢?
(1)方案 1
如果业务允许的话:
synchronized (LOCK) {
updateById();
}
或:
-
单线程消费
-
MQ 串行 consumer
(2)拆热点
把 1 行拆成 N 行:
id = 1_0
id = 1_1
id = 1_2
...
-
按 hash / 线程 / 时间分桶
-
最终再汇总
👉 数据库才能并行
(3)用 Redis / 内存抗并发
请求 → Redis incr → 异步落库
数据库只承受低频写。类似于电商平台的秒杀这种高并发业务:
用户请求
↓
网关限流
↓
内存缓存(Redis)原子扣减库存
↓
排队消息队列(MQ)
↓
后台异步落库(写订单、减库存)
(4)CAS / 乐观锁(适合可失败场景)
UPDATE table
SET value = value + 1, version = version + 1
WHERE id = ? AND version = ?
二、(行锁)锁表会阻塞该表的哪些操作
如锁住的是id=1这一行,那么同时发生:
| 操作 | 是否阻塞 |
|---|---|
| SELECT count FROM A WHERE id = 1 | ❌ 不阻塞 |
| SELECT ... FOR UPDATE | ✅ 阻塞 |
| UPDATE A SET ... WHERE id = 1 | ✅ 阻塞 |
| UPDATE A SET ... WHERE id = 2 | ❌(不同主键) |
1、普通 SELECT(不阻塞)
SELECT * FROM A WHERE id = 1;
2、加锁读(阻塞)
SELECT * FROM A WHERE id = 1 FOR UPDATE;
或者
SELECT * FROM A WHERE id = 1 LOCK IN SHARE MODE;
3、UPDATE / DELETE(阻塞)
UPDATE A SET ... WHERE id = 1;
三、(行锁)锁表会影响其他表吗
A 表发生写锁(行锁 / 表锁)
❌ 不会直接影响其他表(B、C...)的读写
✅ 但会"间接影响"其他表,常见于连接、事务、线程被拖住的情况
A 表被锁 │
└──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
同一事务 连接池耗尽 MySQL连接数满
锁住B/C表 │ │
│ ┌───┴───┐ ┌──┴──┐
▼ ▼ ▼ ▼ ▼
B/C表 B表查询 C表 所有表 所有表
阻塞 超时 超时 拒绝连接 拒绝连接
1、数据库连接池耗尽
SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';
验证方法:看应用日志里是否有类似 Cannot get a connection, pool error Timeout waiting for idle object 的报错。会受影响的是"所有连到同一 MySQL 实例的 Pod",不是"只有同一个 Pod"。
2、MySQL Server 层线程耗尽
SHOW VARIABLES LIKE 'max_connections'; -- 最大连接数
SHOW STATUS LIKE 'Threads_connected'; -- 当前连接数