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不适用的场景。

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

相关推荐
2301_8002561117 小时前
数据库设计中的 “数据依赖→设计异常→关系分解(范式)” 核心逻辑
数据库·postgresql
码间拾光・菲林斯17 小时前
PostgreSQL 微服务架构开发实战:数据一致性、多租户设计与框架集成
微服务·postgresql·架构
IvorySQL17 小时前
PostgreSQL 的 SQL 查询之旅
数据库·人工智能·postgresql·开源
TimerShaft1 天前
CentOS7安装PostgresSQL和PGVector
postgresql·centos·pgvector
2301_800256111 天前
R-Tree创建与遍历,R-Tree在4类空间查询中的应用,实现4类空间查询的各类算法[第8章]
数据库·算法·机器学习·postgresql·r-tree
梦想画家1 天前
实战优化:基于 pgvector 的向量存储与检索效率提升全攻略
postgresql·pgvector·语义检索
AC赳赳老秦2 天前
Python 爬虫进阶:DeepSeek 优化反爬策略与动态数据解析逻辑
开发语言·hadoop·spring boot·爬虫·python·postgresql·deepseek
horizon72742 天前
Windows安装pgvector
postgresql·pgvector
l1t2 天前
DeepSeek辅助编写的利用唯一可选数求解数独SQL
数据库·sql·算法·postgresql