PostgreSQL的“时光机”MVCC和锁机制是怎么搞定高并发的?

153 阅读18分钟

一、并发控制与MVCC基础

1.1 什么是MVCC?

MVCC(Multi-Version Concurrency Control,多版本并发控制)是PostgreSQL实现高并发的核心机制。你可以把它想象成数据库的“时光机” :每个事务启动时,都会拿到一个数据快照 ——相当于事务开始时刻的数据库状态。事务中的读操作(SELECT)只会看到这个快照里的数据,不会被其他同时运行的事务的修改影响;而写操作(INSERT/UPDATE/DELETE)会生成 新版本数据,旧版本会保留到没有事务需要它为止(由事务ID和可见性规则决定)。

比如,事务A在9点启动,读取到用户表中“Alice”的年龄是25;事务B在9点01分修改“Alice”的年龄为26并提交,但事务A在9点02分再次读取时,看到的还是25——因为它的快照是9点的状态。这种“快照隔离”(Snapshot Isolation)是MVCC的核心特性,让读事务和写事务可以无锁并发(除非使用串行化隔离级别)。

1.2 MVCC与锁的关系

MVCC减少了锁的使用,但没有完全替代锁。比如:

  • 写事务之间需要锁:如果两个事务同时修改同一行,PostgreSQL会用行级锁保证只有一个事务能修改(避免“脏写”);
  • 某些操作需要显式锁:比如CREATE INDEX需要表级锁,避免其他事务修改表结构;
  • 串行化隔离级别:会强制加锁来保证严格的串行执行。

简单来说,MVCC解决了“读-写冲突”(读事务不需要等写事务),而锁解决了“写-写冲突”(写事务之间需要互斥)。

二、PostgreSQL的锁机制分类

PostgreSQL的锁按粒度分为表级锁、行级锁、页级锁(较少用),按模式分为共享锁(Share)、排他锁(Exclusive)等。以下是最常用的锁类型:

2.1 表级锁:控制整张表的访问

表级锁的粒度大,影响范围广,尽量少用。常见的表级锁模式:

锁模式用途冲突的锁模式
ACCESS SHARESELECT(隐式加锁)EXCLUSIVE、ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT/UPDATE/DELETE(隐式加锁)SHARE、SHARE ROW EXCLUSIVE等
SHARECREATE INDEX(隐式加锁)ROW EXCLUSIVE及以上
EXCLUSIVEALTER TABLE(部分操作)几乎所有锁
ACCESS EXCLUSIVEDROP TABLE、TRUNCATE(隐式加锁)所有锁

比如,当你执行DELETE FROM users WHERE id=1时,PostgreSQL会自动给users表加ROW EXCLUSIVE锁 (防止其他事务加SHARE锁创建索引),同时给id=1的行加行级排他锁

2.2 行级锁:控制单行的修改

行级锁是最常用的锁类型,粒度小,并发高。常见的行级锁模式:

  • FOR UPDATE:排他锁,锁定行用于修改,其他事务不能修改或加FOR UPDATE锁;
  • FOR SHARE:共享锁,锁定行用于读取,其他事务可以加FOR SHARE但不能加FOR UPDATE;
  • NOWAIT:可选参数,加锁失败时立即报错,不等待;
  • SKIP LOCKED:可选参数,跳过已锁定的行,不等待。

示例:

-- 锁定id=1的行用于修改,加锁失败立即报错
SELECT * FROM users WHERE id=1 FOR UPDATE NOWAIT;

-- 锁定未被锁定的行,跳过已锁定的行(适用于秒杀、任务队列)
SELECT * FROM orders WHERE status='pending' FOR UPDATE SKIP LOCKED LIMIT 10;

2.3 其他锁类型

  • 页级锁:锁定数据页(默认8KB),用于批量修改(比如VACUUM);
  • 函数级锁:锁定用户定义的函数,防止并发修改函数定义。

三、锁等待与死锁的成因

3.1 锁等待:为什么事务会“卡住”?

锁等待是指一个事务需要的锁被另一个事务持有,必须等待对方释放锁才能继续。最常见的场景是长事务

示例:长事务导致锁等待

事务A(终端1):

BEGIN;
-- 模拟耗时操作(比如调用外部API)
SELECT pg_sleep(10); -- 睡眠10秒,持有锁10秒
UPDATE users SET name='Alice' WHERE id=1; -- 加行级排他锁
COMMIT;

事务B(终端2):

BEGIN;
-- 需要修改同一行,等待事务A释放锁
UPDATE users SET name='Bob' WHERE id=1; -- 卡住!
COMMIT;

事务B会一直等待,直到事务A提交/回滚,或者触发lock_timeout(默认无限等待)。

3.2 死锁:循环等待的陷阱

死锁是两个或多个事务互相等待对方的锁,形成循环。比如:

  • 事务A持有行1的锁,需要行2的锁;
  • 事务B持有行2的锁,需要行1的锁;
  • 两者互相等待,永远无法结束。

示例:死锁的产生

事务A(终端1):

BEGIN;
UPDATE users SET name='Alice' WHERE id=1; -- 持有行1的锁
UPDATE users SET name='Bob' WHERE id=2; -- 等待行2的锁
COMMIT;

事务B(终端2):

BEGIN;
UPDATE users SET name='Charlie' WHERE id=2; -- 持有行2的锁
UPDATE users SET name='David' WHERE id=1; -- 等待行1的锁
COMMIT;

同时运行这两个事务,PostgreSQL会立即检测到死锁,终止其中一个事务并返回:

ERROR: deadlock detected
DETAIL: Process 123 waits for ShareLock on transaction 456; blocked by process 789.
Process 789 waits for ShareLock on transaction 123; blocked by process 123.
HINT: See server log for details.

四、避免锁等待与死锁的优化实践

4.1 减少事务长度:短事务是王道

长事务是锁等待和死锁的“罪魁祸首”。优化方法:

  • 将非数据库操作移出事务:比如调用API、处理文件等操作,放到事务外;
  • 拆分大事务:将一个长事务拆成多个短事务(比如批量更新拆成每次更新100行)。

优化后的长事务示例

原长事务:

BEGIN;
SELECT * FROM users WHERE id=1; -- 业务逻辑:调用API验证用户
SELECT pg_sleep(10); -- 模拟API调用
UPDATE users SET status='verified' WHERE id=1;
COMMIT;

优化后(将API调用移出事务):

-- 先执行非数据库操作
SELECT * FROM users WHERE id=1; -- 查用户信息
SELECT pg_sleep(10); -- 调用API验证
-- 再启动短事务更新
BEGIN;
UPDATE users SET status='verified' WHERE id=1; -- 锁持有时间仅几毫秒
COMMIT;

4.2 合理选择锁级别:能行级不表级

表级锁会锁定整张表,导致所有事务等待。比如:

  • 不要用LOCK TABLE users;(表级排他锁),除非你真的需要锁定整张表;
  • 优先用行级锁(FOR UPDATE),只锁定需要修改的行。

反例:不必要的表级锁

-- 错误:锁定整张表,导致其他事务无法操作users表
LOCK TABLE users IN EXCLUSIVE MODE;
UPDATE users SET name='Alice' WHERE id=1;

正例:行级锁

-- 正确:只锁定id=1的行,其他行可以正常操作
UPDATE users SET name='Alice' WHERE id=1; -- 隐式行级锁

4.3 乐观锁:用版本号替代悲观锁

乐观锁假设冲突很少发生,通过版本号字段来检测冲突,不需要加锁。适用于读多写少的场景(比如商品库存扣减)。

示例:乐观锁实现库存扣减

  1. 创建表(加version字段):
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    stock INT NOT NULL DEFAULT 0,
    version INT NOT NULL DEFAULT 0 -- 版本号,每次更新+1
);
  1. 插入测试数据:
INSERT INTO products (name, stock) VALUES ('Apple', 100);
  1. 扣减库存(只有版本号匹配时才更新):
-- 事务A:扣减1个库存
BEGIN;
UPDATE products 
SET stock = stock - 1, version = version + 1 
WHERE id=1 AND version=0; -- 版本号为0时才更新
COMMIT; -- 成功,version变为1

-- 事务B:此时版本号已变为1,更新失败
BEGIN;
UPDATE products 
SET stock = stock - 1, version = version + 1 
WHERE id=1 AND version=0; -- 返回0行,更新失败
COMMIT;
  1. 处理失败:重试(重新查版本号再更新)或提示用户“库存已变化,请刷新”。

4.4 避免循环依赖:统一资源顺序

死锁的核心是循环等待,解决方法是统一事务处理资源的顺序。比如:

  • 所有事务修改行时,都按id从小到大处理;
  • 所有事务修改表时,都按表名字典序处理。

优化后的死锁示例

事务A(终端1):

BEGIN;
UPDATE users SET name='Alice' WHERE id=1; -- 先处理id=1
UPDATE users SET name='Bob' WHERE id=2; -- 再处理id=2
COMMIT;

事务B(终端2):

BEGIN;
UPDATE users SET name='David' WHERE id=1; -- 先处理id=1(和A顺序一致)
UPDATE users SET name='Charlie' WHERE id=2; -- 再处理id=2
COMMIT;

这样事务B不会持有id=2的锁等待id=1,循环被打破,死锁消失。

4.5 NOWAIT与SKIP LOCKED:拒绝无限等待

  • NOWAIT:加锁失败时立即报错,不等待;
  • SKIP LOCKED:跳过已锁定的行,只处理未被锁定的行。

示例1:用NOWAIT避免等待

-- 尝试锁定id=1的行,失败立即报错
SELECT * FROM users WHERE id=1 FOR UPDATE NOWAIT;
-- 如果行已被锁定,返回:ERROR: could not obtain lock on row in relation "users"

示例2:用SKIP LOCKED处理任务队列

假设你有一个任务表tasks,状态为pending的任务需要被 worker 处理:

-- 每个worker获取10个未被锁定的任务(避免多个worker抢同一任务)
SELECT * FROM tasks WHERE status='pending' FOR UPDATE SKIP LOCKED LIMIT 10;

SKIP LOCKED适用于不需要处理所有行的场景(比如秒杀、任务队列),避免 worker 之间互相等待。

4.6 监控锁状态:用pg_locks看透锁持有情况

PostgreSQL提供pg_locks视图,用于查看当前的锁状态。比如:

-- 查看所有持有锁的事务
SELECT 
    pg_stat_activity.pid, -- 进程ID
    pg_stat_activity.query, -- 执行的SQL
    pg_locks.relation::regclass, -- 锁的表名
    pg_locks.locktype, -- 锁类型(表/行)
    pg_locks.mode -- 锁模式
FROM pg_locks
JOIN pg_stat_activity ON pg_locks.pid = pg_stat_activity.pid
WHERE pg_stat_activity.waiting = 'f'; -- 不包含等待中的事务

如果发现某个进程持有锁超过1分钟,可能是长事务,需要终止:

-- 终止进程ID为123的事务(注意:会回滚事务)
SELECT pg_terminate_backend(123);

五、实践示例:从问题到优化

5.1 示例1:长事务导致锁等待的优化

问题:事务A持有锁10秒,事务B等待。
优化:将非数据库操作移出事务,缩短锁持有时间。
优化后代码

-- 事务A(终端1):先处理非数据库操作
SELECT * FROM users WHERE id=1; -- 查用户信息
SELECT pg_sleep(10); -- 调用API(移出事务)
-- 启动短事务更新
BEGIN;
UPDATE users SET name='Alice' WHERE id=1; -- 锁持有时间<1ms
COMMIT;

-- 事务B(终端2):立即执行,无需等待
BEGIN;
UPDATE users SET name='Bob' WHERE id=1; -- 成功
COMMIT;

5.2 示例2:死锁的解决

问题:两个事务互相等待对方的锁。
优化:统一事务处理行的顺序(先id=1,再id=2)。
优化后代码

-- 事务A(终端1):先id=1,再id=2
BEGIN;
UPDATE users SET name='Alice' WHERE id=1;
UPDATE users SET name='Bob' WHERE id=2;
COMMIT;

-- 事务B(终端2):同样先id=1,再id=2
BEGIN;
UPDATE users SET name='David' WHERE id=1;
UPDATE users SET name='Charlie' WHERE id=2;
COMMIT;

5.3 示例3:乐观锁解决库存超卖

问题:两个事务同时扣减库存,导致超卖(比如库存100,扣减两次后变成98,而不是99)。
优化:用版本号字段检测冲突。
优化后代码

-- 事务A:扣减1个库存(version=0)
BEGIN;
UPDATE products SET stock=stock-1, version=version+1 WHERE id=1 AND version=0; -- 成功,version=1
COMMIT;

-- 事务B:此时version=1,更新失败(返回0行)
BEGIN;
UPDATE products SET stock=stock-1, version=version+1 WHERE id=1 AND version=0; -- 失败
COMMIT;

-- 处理失败:重试
-- 重新查版本号
SELECT version FROM products WHERE id=1; -- 得到version=1
-- 再次更新
BEGIN;
UPDATE products SET stock=stock-1, version=version+1 WHERE id=1 AND version=1; -- 成功
COMMIT;

六、课后Quiz

问题1:MVCC的“快照隔离”是什么?它解决了什么问题?

答案:快照隔离是指事务启动时拿到数据的快照,读操作只看到快照里的数据。它解决了读-写冲突 ——读事务不需要等待写事务释放锁,写事务也不需要等待读事务,大大提升了并发性能。

问题2:如何用SKIP LOCKED处理秒杀场景?

答案:假设你有一个商品表seckill_products,库存为100:

-- 用户秒杀时,获取1个未被锁定的商品(避免多个用户抢同一商品)
BEGIN;
SELECT * FROM seckill_products WHERE stock>0 FOR UPDATE SKIP LOCKED LIMIT 1;
-- 扣减库存
UPDATE seckill_products SET stock=stock-1 WHERE id=?;
COMMIT;

SKIP LOCKED会跳过已被其他用户锁定的商品,每个用户只能拿到未被锁定的商品,避免超卖和等待。

问题3:死锁的四个必要条件是什么?如何打破?

答案:死锁的四个条件是:互斥(资源只能被一个事务持有)、请求保持(持有资源的同时请求新资源)、不可剥夺(资源不能被强制夺取)、循环等待(事务互相等待对方的资源)。打破死锁的关键是 打破循环等待——统一事务处理资源的顺序。

七、常见报错与解决方案

报错1:ERROR: deadlock detected

原因:两个或多个事务形成循环等待(比如A等B的锁,B等A的锁)。
解决步骤

  1. 查看死锁日志(postgresql.log),找到冲突的事务;
  2. 统一事务处理资源的顺序(比如按id排序);
  3. 缩短事务长度,尽快释放锁。

参考链接

往期文章归档
免费好用的热门在线工具