KES 并发控制与锁机制实战:MVCC、死锁排查与高并发优化指南
开篇的话
做数据库开发这些年,遇到过太多跟并发相关的问题。白天测试环境跑得好好的,一到生产环境并发一高就开始出问题:有时候是数据莫名其妙不对,有时候是系统突然卡死,有时候是用户投诉操作失败。排查下来,十有八九跟锁机制或者并发控制有关。
并发控制是数据库里最难搞懂但又最重要的部分之一。很多开发者只会写 CRUD,对数据库怎么处理并发、怎么加锁、怎么隔离事务一知半解。等到出了生产问题,才知道要补这块的知识。我在 KingbaseES 上踩过不少并发相关的坑,也帮其他团队排查过各种问题。这篇文章专门聊 KES 的并发控制和锁机制,重点讲 MVCC 原理、各种锁的行为、死锁排查以及高并发场景下的优化策略。内容偏实战,大量使用真实案例。如果你正在开发高并发系统,或者被并发问题折磨过,这篇文章应该能帮到你。
一、理解 MVCC 与事务隔离
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 KES 实现并发控制的核心机制。传统数据库用锁来控制并发,读锁和写锁互斥,并发度很低。MVCC 的思路完全不同:它让读操作不阻塞写操作,写操作也不阻塞读操作,通过维护数据的多个版本来实现。
sql
-- 假设有一个用户表
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100),
balance NUMERIC(10,2)
);
INSERT INTO users (username, balance) VALUES ('张三', 1000);
当事务 A 更新这条数据时,KES 不会直接修改原来的行,而是创建一个新版本。每个数据行都有两个隐藏字段:xmin(创建这个版本的事务 ID)和 xmax(删除或更新这个版本的事务 ID)。通过这两个字段,KES 能判断在某个事务的视角下,哪个版本的数据是可见的。
sql
-- 事务 A
BEGIN;
SELECT balance FROM users WHERE username = '张三';
-- 假设返回 1000
-- 此时事务 B 在另一个会话中更新并提交数据
-- UPDATE users SET balance = 800 WHERE username = '张三';
-- COMMIT;
-- 事务 A 再次查询
SELECT balance FROM users WHERE username = '张三';
-- 仍然返回 1000!因为事务 A 的快照是在第一次查询时建立的
这就是 MVCC 的魔力:事务看到的是它开始时刻的数据库快照,不受其他并发事务的影响。
MVCC 有个副作用:旧版本的数据不会立即删除,而是变成"死元组"(Dead Tuple)。VACUUM 负责回收死元组占用的空间,KES 有自动 VACUUM 机制(autovacuum),一般不需要手动干预。但对于写入量特别大的表,可能需要调整 autovacuum 的参数:
sql
-- 针对特定表调整 autovacuum 参数
ALTER TABLE orders SET (
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_threshold = 1000,
autovacuum_analyze_scale_factor = 0.05
);
写入频繁的表要把 scale_factor 调小,让 VACUUM 更积极。我见过一个表,每天更新几十万次,autovacuum 一直追不上写入速度,半年下来表膨胀了三倍,查询性能下降了十几倍。后来把 scale_factor 从默认的 0.2 调到 0.05,问题解决。
KES 支持三种事务隔离级别:READ COMMITTED、REPEATABLE READ 和 SERIALIZABLE。READ COMMITTED 是默认级别,每次查询都看到查询开始时刻的快照,同一个事务里的两次相同查询可能返回不同结果(不可重复读),大多数业务场景下完全够用。REPEATABLE READ 整个事务使用同一个快照,保证可重复读,在 KES 中也不会出现幻读,但更新冲突时会报错,需要应用层捕获错误并重试。SERIALIZABLE是最严格的隔离级别,能保证事务的执行效果等同于某种串行执行顺序,但性能开销最大,一般只在金融交易等对一致性要求极高的场景下使用。
隔离级别选择建议:
- 90% 的场景: READ COMMITTED 就够,性能最好,行为也符合直觉
- 报表统计、数据导出: REPEATABLE READ,保证统计过程中数据不会变化
- 金融交易、账户扣减: REPEATABLE READ 或 SERIALIZABLE,配合重试机制
不要因为"隔离级别越高越好"的误解而盲目使用 SERIALIZABLE。高隔离级别意味着更多的冲突和重试,反而可能降低系统的可用性。
二、锁机制与死锁排查
锁是并发控制的另一套机制,跟 MVCC 配合使用。行级锁 是日常开发中最常遇到的锁类型,其中FOR UPDATE是排他锁,锁定后其他事务的 UPDATE、DELETE、SELECT FOR UPDATE 都会被阻塞,但普通 SELECT 不受影响。
sql
-- FOR UPDATE:排他锁,典型场景是扣减库存、账户余额操作
BEGIN;
-- 锁定库存记录
SELECT id, product_id, quantity FROM stock
WHERE product_id = 1001
FOR UPDATE;
-- 扣减库存
UPDATE stock SET quantity = quantity - 5
WHERE product_id = 1001;
COMMIT;
FOR UPDATE 有两个实用的变体:
sql
-- FOR UPDATE NOWAIT:抢不到锁立即报错,不等待
SELECT * FROM task_queue
WHERE status = 0
FOR UPDATE NOWAIT;
-- FOR UPDATE SKIP LOCKED:跳过已被锁定的行
SELECT * FROM task_queue
WHERE status = 0
FOR UPDATE SKIP LOCKED
LIMIT 10;
FOR UPDATE NOWAIT 适合"要么拿到锁要么放弃"的场景,避免长时间等待。FOR UPDATE SKIP LOCKED 是做任务队列的神器,多个消费者并发取任务,各自拿到不同的任务,互不阻塞。我之前有个消息分发系统,用 SKIP LOCKED 之前吞吐量是每秒 200 条,加上之后直接飙升到每秒 800 条。
表级锁比行锁更重,一般由 DDL 操作自动加。常见的表锁模式从轻到重包括:ROW SHARE、ROW EXCLUSIVE、SHARE、EXCLUSIVE 和 ACCESS EXCLUSIVE。
sql
-- 手动加表锁(一般不需要)
LOCK TABLE orders IN EXCLUSIVE MODE;
-- ACCESS EXCLUSIVE 是最重的锁,ALTER TABLE/DROP TABLE 自动加的锁,会阻塞一切操作
ALTER TABLE users ADD COLUMN email VARCHAR(100);
其中 ACCESS EXCLUSIVE 是最重的锁,会阻塞一切操作。如果你在数据库里看到大量锁等待,先检查是不是有人在执行 DDL 操作。
死锁是两个或多个事务互相等待对方释放锁,形成循环依赖。KES 会自动检测死锁并回滚其中一个事务。
sql
-- 典型死锁场景:更新顺序不一致
-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 事务 B(并发执行)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
-- 事务 A 锁了 id=1,等待 id=2;事务 B 锁了 id=2,等待 id=1。死锁!
典型的死锁场景包括:更新顺序不一致,解决方案是统一更新顺序;索引缺失导致锁升级(UPDATE 扫描全表对每一行都加锁),解决方案是给 WHERE 条件里的字段加索引。
java
// 解决方案:统一更新顺序
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 保证更新顺序一致
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
accountMapper.updateBalance(firstId, ...);
accountMapper.updateBalance(secondId, ...);
}
死锁排查方法:
sql
-- 1. 查看死锁日志(在服务器终端执行)
grep "deadlock" /data/kingbase/data/sys_log/kingbase-2026-06-23.log
-- 2. 查看当前锁等待关系
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
now() - blocked.query_start AS wait_duration
FROM sys_stat_activity blocked
JOIN sys_locks l ON blocked.pid = l.pid AND NOT l.granted
JOIN sys_locks granted ON l.locktype = granted.locktype
AND l.database IS NOT DISTINCT FROM granted.database
AND l.relation IS NOT DISTINCT FROM granted.relation
AND granted.granted = true
JOIN sys_stat_activity blocking ON granted.pid = blocking.pid
WHERE blocked.pid != blocking.pid;
-- 3. 设置锁超时时间(毫秒)
SET lock_timeout = 5000;
-- 等待超过 5 秒还没拿到锁会直接报错,比死锁检测更快速
三、高并发优化实战
理解了 MVCC 和锁机制,下一步就是如何在高并发场景下应用这些知识。
优化事务粒度
事务越短越好,锁持有时间越短越好。把不需要在事务里做的操作移出去,只把必须原子性的操作包在事务里。
sql
-- 不好的写法:事务太长
BEGIN;
SELECT * FROM users WHERE id = 1;
-- 做一些耗时的计算(几秒钟)
UPDATE users SET last_login = now() WHERE id = 1;
UPDATE users SET login_count = login_count + 1 WHERE id = 1;
COMMIT;
-- 好的写法:事务尽量短
SELECT * FROM users WHERE id = 1;
-- 在应用层做计算
BEGIN;
UPDATE users SET last_login = now(), login_count = login_count + 1
WHERE id = 1;
COMMIT;
批量操作优化
批量更新能减少锁的获取和释放次数,也能减少 WAL 日志的写入。
sql
-- 不好的写法:逐条更新
UPDATE orders SET status = 1 WHERE id = 1;
UPDATE orders SET status = 1 WHERE id = 2;
UPDATE orders SET status = 1 WHERE id = 3;
-- 好的写法:一条 SQL 批量更新
UPDATE orders SET status = 1 WHERE id IN (1, 2, 3);
大批量删除要分批处理,避免长时间持有大量锁。
sql
-- 分批删除,每次 5000 行
DO $$
DECLARE
batch_size INT := 5000;
deleted INT;
BEGIN
LOOP
DELETE FROM logs
WHERE ctid IN (
SELECT ctid FROM logs
WHERE created_at < '2025-01-01'
LIMIT batch_size
);
GET DIAGNOSTICS deleted = ROW_COUNT;
COMMIT;
EXIT WHEN deleted < batch_size;
PERFORM pg_sleep(0.5);
END LOOP;
END $$;
乐观锁与悲观锁
悲观锁使用 FOR UPDATE,假设冲突一定会发生,适合冲突概率高(>10%)的场景。
sql
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
乐观锁使用版本号或时间戳,假设冲突很少发生,适合冲突概率低(<5%)的场景,性能更好。
sql
-- 查询时带上版本号
SELECT balance, version FROM accounts WHERE id = 1;
-- 假设返回 balance=1000, version=5
-- 更新时检查版本号
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;
-- 如果返回 0 行,说明版本号已变化,需要重试
不确定时先测一下冲突率再决定。我在一个抢购系统里做过对比:用悲观锁方案,并发 1000 时 TPS 是 800;改用乐观锁方案,TPS 提升到 1500,因为抢购场景虽然并发高,但真正冲突的概率并不高。
并发扣减库存方案
库存扣减是高并发场景的经典问题。
方案一:简单 UPDATE(适合低并发)
sql
UPDATE stock SET quantity = quantity - 5
WHERE product_id = 1001 AND quantity >= 5;
-- 如果返回 0 行,说明库存不足
方案二:乐观锁(适合中等并发)
sql
-- 查询库存
SELECT quantity, version FROM stock WHERE product_id = 1001;
-- 更新时检查版本号
UPDATE stock
SET quantity = quantity - 5, version = version + 1
WHERE product_id = 1001 AND version = :old_version AND quantity >= 5;
方案三:预扣库存(适合高并发)
sql
-- 创建预扣表
CREATE TABLE stock_reservation (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
expire_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
CREATE INDEX idx_reservation_product ON stock_reservation(product_id);
-- 下单时先预扣
INSERT INTO stock_reservation (product_id, quantity, expire_at)
VALUES (1001, 5, now() + INTERVAL '30 minutes');
-- 定时任务清理过期的预扣记录
DELETE FROM stock_reservation WHERE expire_at < now();
-- 支付成功后,真正扣减库存
UPDATE stock SET quantity = quantity - 5 WHERE product_id = 1001;
DELETE FROM stock_reservation WHERE product_id = 1001;
预扣库存的方案把并发冲突从库存表转移到了预扣表,预扣表的写入冲突比库存表的行锁冲突更容易处理,而且即使用户不支付,30 分钟后预扣自动失效,库存自动恢复。
连接池配置
应用层的连接池配置直接影响数据库的并发行为。
yaml
# HikariCP 配置示例
spring:
datasource:
hikari:
maximum-pool-size: 20 # 最大连接数
minimum-idle: 5 # 最小空闲连接
connection-timeout: 30000 # 获取连接超时(毫秒)
idle-timeout: 600000 # 空闲连接超时
max-lifetime: 1800000 # 连接最大存活时间
连接池大小设置原则:不是越大越好,每个连接都会占用数据库资源。CPU 密集型应用连接数约等于 CPU 核心数,IO 密集型应用连接数约为 CPU 核心数 × 2~4,一般业务系统 20-50 个连接通常够用。连接池太大反而会导致数据库连接数打满,触发 max_connections 限制。我见过一个项目配置了 200 个连接,结果数据库默认 max_connections 也是 200,连接全被这个应用占满,其他应用全连不上。
四、实战案例分析
最后分享几个真实的并发问题排查和优化案例。
案例一:连接池耗尽导致系统雪崩
某电商平台晚高峰期间,系统突然大量报错"Connection pool exhausted"。
排查过程:
sql
-- 1. 检查数据库连接数
SELECT count(*) FROM sys_stat_activity;
-- 返回 200,达到 max_connections 上限
-- 2. 查看连接分布
SELECT application_name, state, count(*)
FROM sys_stat_activity
GROUP BY 1, 2
ORDER BY 3 DESC;
-- 发现大量 idle in transaction 状态的连接
-- 3. 检查长事务
SELECT pid, usename, now() - xact_start AS duration, query
FROM sys_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > INTERVAL '10 minutes'
ORDER BY duration DESC;
-- 发现几个事务打开了十几分钟都没提交
根本原因:代码里有个接口,打开了事务后调用第三方 API,但第三方 API 响应很慢,导致事务一直挂着不提交,持有的锁不释放,连接不归还。
解决方案:
java
// 1. 绝对不要在事务中调用外部服务
@Transactional(timeout = 30) // 30 秒超时
public void processOrder(Order order) {
// 只包含数据库操作
}
sql
-- 2. 在数据库层面设置超时保护 (kingbase.conf)
idle_in_transaction_session_timeout = 300000 -- 5 分钟
statement_timeout = 60000 -- 1 分钟
案例二:大表 ALTER TABLE 导致系统卡死
运维人员在下午 3 点执行了一条 ALTER TABLE 添加字段的语句,之后系统所有查询都变慢。
排查过程:
sql
-- 1. 查看当前锁等待
SELECT blocked.pid, blocked.query,
now() - blocked.query_start AS wait_duration
FROM sys_stat_activity blocked
WHERE wait_event_type = 'Lock'
ORDER BY wait_duration DESC;
-- 发现大量查询在等待 AccessExclusiveLock
-- 2. 查看谁持有这个锁
SELECT pid, query, now() - xact_start AS duration
FROM sys_stat_activity
WHERE pid IN (
SELECT holder.pid
FROM sys_locks l
JOIN sys_stat_activity holder ON l.pid = holder.pid
WHERE l.granted = true AND l.locktype = 'relation'
);
-- 发现是那个 ALTER TABLE 操作在持有锁
根本原因:ALTER TABLE 会加 AccessExclusiveLock,阻塞所有读写操作,对于大表需要重写整个表文件,耗时很长(5000 万行执行了 20 多分钟)。
解决方案:
sql
-- 1. DDL 操作要在业务低峰期执行(凌晨 2-4 点)
-- 2. 对于添加字段,如果字段有默认值,可以用更快的方式(KES 9.0+ 支持)
ALTER TABLE large_table ADD COLUMN new_column VARCHAR(50) DEFAULT 'default_value';
-- 这种方式只修改系统表,不重写数据文件,瞬间完成
案例三:高并发下的数据不一致
某金融系统在并发转账时,偶尔出现账户余额对不上的情况。
问题代码:
java
// 有问题的代码 - 查询和更新之间有其他事务修改数据
public void transfer(Long fromId, Long toId, BigDecimal amount) {
BigDecimal fromBalance = accountMapper.selectBalance(fromId);
BigDecimal toBalance = accountMapper.selectBalance(toId);
if (fromBalance.compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
accountMapper.updateBalance(fromId, fromBalance.subtract(amount));
accountMapper.updateBalance(toId, toBalance.add(amount));
}
解决方案一:使用 FOR UPDATE
java
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 锁定账户记录
Account from = accountMapper.selectForUpdate(fromId);
Account to = accountMapper.selectForUpdate(toId);
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
accountMapper.updateBalance(fromId, from.getBalance().subtract(amount));
accountMapper.updateBalance(toId, to.getBalance().add(amount));
}
对应的 SQL:
sql
SELECT id, balance FROM accounts WHERE id = #{id} FOR UPDATE;
解决方案二:使用原子 UPDATE
sql
-- 直接在 SQL 里做计算,避免应用层的读写分离
UPDATE accounts
SET balance = balance - #{amount}
WHERE id = #{fromId} AND balance >= #{amount};
UPDATE accounts
SET balance = balance + #{amount}
WHERE id = #{toId};
这种方式不需要显式加锁,UPDATE 本身就加了排他锁。而且第二条 UPDATE 依赖第一条的结果,天然串行化。
优化效果:改用 FOR UPDATE 方案后,数据不一致问题彻底消失。并发 TPS 从 500 降到 350(因为锁等待增加),但数据准确性得到了保证。
写在最后
并发控制和锁机制是数据库开发中最难的部分之一,但也是必须掌握的核心技能。MVCC 让你理解了为什么读不会阻塞写,锁机制让你知道了怎么保护数据的一致性,事务隔离级别让你能在性能和一致性之间做权衡。
实际开发中,我建议遵循这些原则:
- 事务越短越好,不要在事务中做耗时操作
- 能用乐观锁的场景优先用乐观锁
- 批量操作优于逐条操作
- 设置合理的超时(锁超时、事务超时、语句超时)
- 定期检查死锁日志和锁等待情况
- 大表 DDL 要在低峰期做,或者用在线方案
KingbaseES 在并发控制方面的实现跟 PostgreSQL 一脉相承,成熟稳定。遇到并发问题不要慌,按照"监控发现 → 查看锁等待 → 分析事务 → 定位瓶颈 → 优化代码"的流程一步步来,大部分问题都能解决。
最后提醒一点:并发优化不是越早越好。先把功能做对,再做性能测试,找出真正的并发瓶颈再优化。过早优化往往优化错地方,浪费时间不说,还可能引入新的问题。用数据说话,用监控数据指导优化方向,这才是并发优化的正确姿势。
希望这篇文章能帮你建立起系统的并发控制思维。遇到问题多看看锁等待,多分析一下事务行为,慢慢就能写出既安全又高效的代码了。共勉。