引言:数据库里的"抢板凳"游戏
想象一下,你和朋友围着一张摆满零食的桌子抢吃的:有人只想看一眼零食(读数据),有人想直接拿走(改数据),有人想把整张桌子搬走(删表)。如果没人管,桌子肯定会被掀翻------PostgreSQL的显式锁,就是这场"抢零食游戏"的规则和裁判,确保每个人都能按规矩来,不打架、不抢乱。
PostgreSQL的锁体系就像一套精密的"交通规则",覆盖了表、行、页三个维度,还有专门解决"互不相让"的死锁机制,以及灵活的"自定义规则"(咨询锁)。接下来,我们用故事+示例的方式,把这些枯燥的锁概念讲明白。
1 表级锁:给整张零食桌定规矩
表级锁是给整张表上的"大锁",就像给零食桌贴标签:"只许看","可拿零食但不能搬桌子","谁都不许碰"......PostgreSQL定义了8种表级锁模式,核心是"谁和谁不能同时来"。
趣味场景:零食店的营业规则
假设你开了一家零食店(数据库),货架(表)上摆着各种零食(数据),不同顾客(事务)有不同需求,你需要用表级锁来管理:
- ACCESS SHARE(只许看) :顾客只想看货架上有什么零食(SELECT),不拿也不碰。只要没人把货架搬走(ACCESS EXCLUSIVE),多少人看都可以。
- ROW EXCLUSIVE(可拿零食) :顾客要拿走一包薯片(UPDATE/INSERT/DELETE),此时不能有人把货架锁起来盘点(SHARE),但其他人还能看、还能拿其他零食。
- ACCESS EXCLUSIVE(谁都不许碰) :你要把整个货架搬走重装(ALTER TABLE/TRUNCATE),此时任何人都不能看、不能拿,必须等你搬完。
实操示例:手动加表级锁
sql
-- 会话1:给products表加"只许读"的锁(ACCESS SHARE)
BEGIN;
LOCK TABLE products IN ACCESS SHARE MODE;
-- 此时可以正常SELECT,但无法执行ALTER TABLE/TRUNCATE
SELECT * FROM products WHERE category = '零食';
COMMIT;
-- 会话2:尝试给products表加"独占锁"(ACCESS EXCLUSIVE),会被阻塞
BEGIN;
LOCK TABLE products IN ACCESS EXCLUSIVE MODE; -- 等待会话1提交后才会执行
ALTER TABLE products ADD COLUMN stock int;
COMMIT;
-- 冲突示例:ACCESS SHARE和ACCESS EXCLUSIVE互斥
-- 会话1先持有ACCESS SHARE,会话2的ACCESS EXCLUSIVE会等待;反之亦然
核心冲突规则(简化版)
| 锁模式 | 能和谁共存? | 不能和谁共存? |
|---|---|---|
| ACCESS SHARE(只读) | 几乎所有锁 | 只有ACCESS EXCLUSIVE |
| ROW EXCLUSIVE(改数据) | 读锁、行共享锁 | 共享锁、独占锁、ACCESS EXCLUSIVE |
| ACCESS EXCLUSIVE(独占) | 无 | 所有锁 |
实际使用案例:电商商品表的表级锁控制
【场景】电商平台的商品表(products)日常有大量用户查询商品信息(SELECT),运营人员需定期更新商品分类(ALTER TABLE),数据分析师需夜间生成销售统计报表(CREATE INDEX CONCURRENTLY)。
【锁策略设计】:
- 用户查询商品时,PostgreSQL自动加ACCESS SHARE锁,不影响其他查询,仅阻塞ALTER TABLE等加ACCESS EXCLUSIVE锁的操作;
- 运营更新商品分类字段(ALTER TABLE products ADD COLUMN category_level int)时,需加ACCESS EXCLUSIVE锁,需避开查询高峰(如凌晨执行),避免阻塞大量用户查询;
- 数据分析师创建统计索引(CREATE INDEX CONCURRENTLY idx_products_sales ON products(sales_num))时,加SHARE UPDATE EXCLUSIVE锁,不会阻塞用户查询,但会阻塞运营的ALTER TABLE操作,需与运营操作错峰。
【实操验证】:
sql
-- 高峰时段用户查询(自动加ACCESS SHARE锁)
SELECT name, price, stock FROM products WHERE category = 'electronics';
-- 夜间运营更新字段(加ACCESS EXCLUSIVE锁)
BEGIN;
ALTER TABLE products ADD COLUMN category_level int;
UPDATE products SET category_level = 1 WHERE category = 'electronics';
COMMIT;
-- 凌晨分析师创建并发索引(加SHARE UPDATE EXCLUSIVE锁)
CREATE INDEX CONCURRENTLY idx_products_sales ON products(sales_num);
2 行级锁:只锁你想要的那包零食
如果说表级锁是"锁整张桌子",行级锁就是"只锁你手里的那包薯片"------不影响别人拿其他零食,精准又高效。PostgreSQL的行级锁只阻塞"改这行数据的人",不影响"看这行数据的人"。
趣味场景:抢最后一包限量薯片
零食店只剩1包限量薯片(行数据),两个顾客同时想买:
- 顾客A先伸手拿(SELECT ... FOR UPDATE),给这包薯片贴了"我要了"的标签(行级排他锁);
- 顾客B再伸手,发现标签后只能等顾客A松手(事务结束),要么买到,要么发现薯片已经被买走(行被删除)。
实操示例:行级锁的4种模式
sql
-- 会话1:锁定id=100的薯片行(FOR UPDATE,排他锁)
BEGIN;
SELECT * FROM products WHERE id = 100 FOR UPDATE;
-- 此时这行被锁定,其他会话改这行都会等
UPDATE products SET stock = stock - 1 WHERE id = 100;
-- 暂不提交,保持锁
-- 会话2:尝试改同一行,会被阻塞
BEGIN;
UPDATE products SET price = 9.9 WHERE id = 100; -- 等待会话1提交/回滚
COMMIT;
-- 更温和的锁:FOR NO KEY UPDATE(不阻塞FOR KEY SHARE)
BEGIN;
SELECT * FROM products WHERE id = 100 FOR NO KEY UPDATE;
-- 只锁"非主键/外键列",适合普通更新
COMMIT;
-- 共享锁:FOR SHARE(多人可看,都不能改)
BEGIN;
SELECT * FROM products WHERE id = 100 FOR SHARE;
-- 其他会话可以加FOR SHARE,但不能加FOR UPDATE
COMMIT;
行级锁冲突规则
实际使用案例:秒杀活动中的行级锁防超卖
【场景】电商秒杀活动,某款限量100件的商品,大量用户同时抢购,需防止超卖(即最终下单数超过库存数)。
【问题分析】:若不使用行级锁,多个事务同时读取库存(如stock=1),都判断有库存并执行扣减,会导致最终stock变为-1(超卖)。
【解决方案】:使用SELECT ... FOR UPDATE行级锁,确保同一时刻只有一个事务能修改该商品的库存记录。
【实操代码】:
sql
-- 秒杀下单核心逻辑(需在事务中执行)
BEGIN;
-- 锁定目标商品行,防止其他事务同时修改
SELECT stock FROM products WHERE id = 1001 FOR UPDATE;
-- 检查库存是否充足
IF (SELECT stock FROM products WHERE id = 1001) > 0 THEN
-- 扣减库存
UPDATE products SET stock = stock - 1 WHERE id = 1001;
-- 创建订单记录
INSERT INTO orders (product_id, user_id, create_time)
VALUES (1001, #{userId}, NOW());
COMMIT;
RETURN '下单成功';
ELSE
ROLLBACK;
RETURN '库存不足';
END IF;
【说明】:通过FOR UPDATE锁定商品行后,其他抢购该商品的事务会阻塞在SELECT ... FOR UPDATE步骤,等待当前事务提交或回滚后再执行,从而避免超卖。
| 请求的锁 | FOR KEY SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | ✅ | ✅ | ✅ | ❌ |
| FOR SHARE | ✅ | ✅ | ❌ | ❌ |
| FOR NO KEY UPDATE | ❌ | ❌ | ❌ | ❌ |
| FOR UPDATE | ❌ | ❌ | ❌ | ❌ |
3 页级锁:零食盒的"临时锁"
页级锁是PostgreSQL内部用的"小锁"------把表拆成一个个"零食盒"(数据页),操作盒里的零食时,先锁盒子,用完马上放。对应用开发者来说,完全不用关心,PostgreSQL会自动处理,这里只做科普:
- 作用:控制对内存中数据页的读写;
- 特点:用完就放,不持久,开发者无需手动操作。
4 死锁:谁都不让谁的"僵局"
死锁就像两个顾客吵架:
顾客A:你先把我的薯片还给我,我再给你可乐!
顾客B:你先把我的可乐还给我,我再给你薯片!
谁都不让步,最后只能店长(PostgreSQL)出面,把其中一个人劝走(中断事务),打破僵局。
趣味场景:转账引发的死锁
小明要给小红转100元,小红要给小明转50元,两人同时操作:
- 小明的事务:先锁自己的账户(更新余额-100),再想锁小红的账户(+100);
- 小红的事务:先锁自己的账户(更新余额-50),再想锁小明的账户(+50);
- 结果:小明等小红松手,小红等小明松手,死锁!
实操示例:复现并解决死锁
sql
-- 先建表:账户表
CREATE TABLE accounts (
acctnum int PRIMARY KEY,
balance numeric(10,2)
);
INSERT INTO accounts VALUES (11111, 1000), (22222, 1000);
-- 会话1(小明):先更自己,再更小红
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE acctnum = 11111;
-- 暂不提交,保持锁
-- 会话2(小红):先更自己,再更小明
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE acctnum = 22222;
UPDATE accounts SET balance = balance + 50 WHERE acctnum = 11111; -- 等待会话1
-- 回到会话1:尝试更小红,触发死锁
UPDATE accounts SET balance = balance + 100 WHERE acctnum = 22222;
-- PostgreSQL会提示:ERROR: deadlock detected
-- 并中断其中一个事务,另一个继续执行
死锁预防技巧
实际使用案例:多账户转账的死锁避免
【场景】银行转账系统,支持用户之间互转,如用户A转用户B、用户B转用户A,若不控制锁顺序,易引发死锁。
【死锁复现】:如前文"转账引发的死锁"示例,因两个事务锁账户的顺序相反(A先锁A再锁B,B先锁B再锁A),导致死锁。
【解决方案】:统一锁顺序,即所有转账事务都按"账户号从小到大"的顺序锁定账户。
【优化后实操代码】:
sql
-- 通用转账函数(统一按账户号升序锁)
CREATE OR REPLACE FUNCTION transfer(from_acct int, to_acct int, amount numeric)
RETURNS VARCHAR AS $$
DECLARE
min_acct int;
max_acct int;
BEGIN
-- 确定账户号大小,统一先锁小号账户
IF from_acct < to_acct THEN
min_acct := from_acct;
max_acct := to_acct;
ELSE
min_acct := to_acct;
max_acct := from_acct;
END IF;
BEGIN;
-- 按统一顺序锁定账户
SELECT balance FROM accounts WHERE acctnum = min_acct FOR UPDATE;
SELECT balance FROM accounts WHERE acctnum = max_acct FOR UPDATE;
-- 检查转出账户余额
IF (SELECT balance FROM accounts WHERE acctnum = from_acct) < amount THEN
ROLLBACK;
RETURN '余额不足';
END IF;
-- 执行转账
UPDATE accounts SET balance = balance - amount WHERE acctnum = from_acct;
UPDATE accounts SET balance = balance + amount WHERE acctnum = to_acct;
COMMIT;
RETURN '转账成功';
END;
$$ LANGUAGE plpgsql;
-- 调用示例(无论谁转谁,都按账户号升序锁)
SELECT transfer(11111, 22222, 100); -- 先锁11111,再锁22222
SELECT transfer(22222, 11111, 50); -- 同样先锁11111,再锁22222
【说明】:通过统一锁顺序,确保所有事务对多个账户的锁定顺序一致,从根本上避免了死锁的发生。
- 统一锁顺序:所有事务都按"账户号从小到大"更新,比如先更11111,再更22222;
- 缩短锁持有时间:事务尽量快,别拿着锁等用户输入;
- 重试机制:捕获死锁错误后,自动重试事务。
5 咨询锁:自定义的"零食店规矩"
咨询锁是PostgreSQL给开发者的"自定义锁"------系统不管你怎么用,全靠你自己守规矩。就像零食店老板和熟客约定:"看到桌上摆着红色杯子,就说明我在盘点,别进来",杯子(咨询锁)的含义由你们自己定。
趣味场景:防止多人同时盘点库存
零食店每天闭店要盘点,你怕多个员工同时盘点出错,就用咨询锁:
- 员工A盘点前,先"占坑":拿一个编号为100的咨询锁;
- 员工B再想盘点,发现100号锁被占,就等员工A盘完再开始;
- 盘点结束,员工A释放锁。
实操示例:会话级vs事务级咨询锁
实际使用案例:分布式任务调度的咨询锁控制
【场景】分布式系统中,多个服务节点需共同执行一批定时任务(如订单超时关闭),需确保同一个任务同一时刻只被一个节点执行,避免重复处理。
【解决方案】:使用事务级咨询锁,每个任务对应一个唯一标识(如任务ID),服务节点执行任务前先获取该标识的咨询锁,获取成功则执行任务,失败则跳过(等待下一轮)。
【实操代码】:
sql
-- 定时任务执行逻辑(每个节点定时调用)
CREATE OR REPLACE FUNCTION execute_timeout_task()
RETURNS VOID AS $$
DECLARE
task RECORD;
lock_acquired BOOLEAN;
BEGIN
-- 遍历未执行的超时订单任务
FOR task IN SELECT id FROM order_timeout_tasks WHERE status = 'pending' LOOP
-- 尝试获取事务级咨询锁(任务ID作为锁标识)
SELECT pg_try_advisory_xact_lock(task.id) INTO lock_acquired;
IF lock_acquired THEN
-- 获取锁成功,执行任务(关闭超时订单)
UPDATE orders SET status = 'closed' WHERE id = task.order_id;
-- 更新任务状态为已完成
UPDATE order_timeout_tasks SET status = 'completed' WHERE id = task.id;
RAISE NOTICE '任务 % 执行成功', task.id;
ELSE
-- 未获取到锁,说明其他节点正在执行
RAISE NOTICE '任务 % 已被其他节点执行,跳过', task.id;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- 服务节点定时调用(如每分钟一次)
SELECT execute_timeout_task();
【说明】:使用pg_try_advisory_xact_lock(非阻塞获取事务级咨询锁),避免节点间相互等待。事务结束后锁自动释放,无需手动解锁,确保任务执行的原子性和唯一性。
sql
-- 1. 会话级咨询锁(会话结束才释放,无视事务回滚)
SELECT pg_advisory_lock(100); -- 占100号锁
-- 此时其他会话调用pg_advisory_lock(100)会阻塞
SELECT pg_advisory_unlock(100); -- 释放锁
-- 2. 事务级咨询锁(事务结束自动释放)
BEGIN;
SELECT pg_advisory_xact_lock(100); -- 事务级锁
-- 盘点操作:SELECT SUM(stock) FROM products;
COMMIT; -- 提交后自动释放锁
-- 避坑:LIMIT和咨询锁一起用要小心
-- 错误写法:可能锁到非预期的行
SELECT pg_advisory_lock(id) FROM products WHERE id > 10 LIMIT 10;
-- 正确写法:先筛选行,再锁
SELECT pg_advisory_lock(q.id) FROM (
SELECT id FROM products WHERE id > 10 LIMIT 10
) q;
6 总结
- 表级锁:按"严格程度"分8种,核心是ACCESS SHARE(只读)和ACCESS EXCLUSIVE(独占)互斥,适合控制整张表的访问;
- 行级锁:精准锁定单行,只阻塞修改,不阻塞读取,常用FOR UPDATE实现"悲观锁";
- 死锁:多事务互等锁导致,预防关键是"统一锁顺序+缩短锁持有时间";
- 咨询锁:自定义锁,分会话级(手动释放)和事务级(自动释放),适合MVCC不适用的场景。
"读尽量不堵读,改尽量少堵读,独占锁堵一切,死锁靠顺序防,咨询锁自己定"。