PostgreSQL 显式锁定:从原理到实践(趣味版)

引言:数据库里的"抢板凳"游戏

想象一下,你和朋友围着一张摆满零食的桌子抢吃的:有人只想看一眼零食(读数据),有人想直接拿走(改数据),有人想把整张桌子搬走(删表)。如果没人管,桌子肯定会被掀翻------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元,两人同时操作:

  1. 小明的事务:先锁自己的账户(更新余额-100),再想锁小红的账户(+100);
  2. 小红的事务:先锁自己的账户(更新余额-50),再想锁小明的账户(+50);
  3. 结果:小明等小红松手,小红等小明松手,死锁!

实操示例:复现并解决死锁

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

【说明】:通过统一锁顺序,确保所有事务对多个账户的锁定顺序一致,从根本上避免了死锁的发生。

  1. 统一锁顺序:所有事务都按"账户号从小到大"更新,比如先更11111,再更22222;
  2. 缩短锁持有时间:事务尽量快,别拿着锁等用户输入;
  3. 重试机制:捕获死锁错误后,自动重试事务。

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 总结

  1. 表级锁:按"严格程度"分8种,核心是ACCESS SHARE(只读)和ACCESS EXCLUSIVE(独占)互斥,适合控制整张表的访问;
  2. 行级锁:精准锁定单行,只阻塞修改,不阻塞读取,常用FOR UPDATE实现"悲观锁";
  3. 死锁:多事务互等锁导致,预防关键是"统一锁顺序+缩短锁持有时间";
  4. 咨询锁:自定义锁,分会话级(手动释放)和事务级(自动释放),适合MVCC不适用的场景。

"读尽量不堵读,改尽量少堵读,独占锁堵一切,死锁靠顺序防,咨询锁自己定"。

相关推荐
晚风_END11 小时前
postgresql数据库|pgbouncer连接池压测和直连postgresql数据库压测对比
数据库·postgresql·oracle·性能优化·宽度优先
小芳矶18 小时前
【langgraph+postgres】用于生产环境的langgraph短期记忆的存取(postgreSQL替代InMemorySaver)
数据库·postgresql·语言模型
tfxing18 小时前
使用 PostgreSQL + pgvector 实现 RAG 向量存储与语义检索(Java 实战)
java·数据库·postgresql
瀚高PG实验室18 小时前
HighGo Database判断流复制主备角色的方法
数据库·postgresql·瀚高数据库
l1t18 小时前
DeepSeek总结的 LEFT JOIN LATERAL相关问题
前端·数据库·sql·postgresql·duckdb
__风__18 小时前
PostgreSQL copy的用法
数据库·postgresql
Carry灭霸1 天前
【BUG】PostgreSQL ERROR invalid input syntax for type numeric XXXX
数据库·postgresql
Dxy12393102161 天前
Python批量写入数据到PostgreSQL性能对比
开发语言·python·postgresql
xuefuhe2 天前
postgresql之patroni高可用
数据库·postgresql
惊鸿Randy2 天前
Docker 环境下 PostgreSQL 16 安装 pgvector 向量数据库插件详细教程(Bitnami 镜像)
数据库·docker·postgresql