WEEK 07

大促备战:优化实战与工程化

大促进入倒计时。技术负责人把「慢查询清单」「后台深分页超时」「历史数据归档」「秒杀防超卖」四座大山压过来--正是从初级到中级的分界线。
目标:从「能写对」到「能写好」,具备工程判断力--这是中级岗和初级岗的分界。
0 / 7 天
剧情 大促前第一件事:建立慢查询的发现与排查机制。
学 · 40 min
  • 五步法:定位 -> 分析 -> 假设 -> 验证 -> 回归

    场景 用户说「后台很卡」--从一句抱怨到一条被治好的 SQL,中间是可复现的流程。

    讲解 定位:哪条慢?(pg_stat_statements / 慢日志,拿语句拿数字);② 分析:EXPLAIN ANALYZE 找瓶颈节点(W6 的功夫);③ 假设:缺索引?统计过期?写法失效?一次只验证一个;④ 验证:改一处、重测、对比;⑤ 回归:确认没伤到别的查询,把前后数据存档。优化是流程,不是灵感。

    试试
    -- 定位:TOP 10 慢语句
    select calls, round(mean_exec_time::numeric,1) as 平均ms, left(query,70)
    from pg_stat_statements order by mean_exec_time desc limit 10;

    易错 跳过「分析」直接加索引是玄学优化:碰巧好了也不知道为什么,下次照样抓瞎。

  • log_min_duration_statement 慢日志配置

    场景 没有 pg_stat_statements 的环境(或要抓「原始完整语句」),慢日志是底线配置。

    讲解 设成 200(毫秒):执行超过 200ms 的语句连原文带耗时自动进日志。改它不用重启:alter system + pg_reload_conf() 即可。日志里能拿到带字面量的完整 SQL(pg_stat_statements 是归一化后的模板),这是两者互补的点。

    试试
    alter system set log_min_duration_statement = 200;
    select pg_reload_conf();          -- 不重启生效
    
    show log_min_duration_statement;  -- 验证:200ms

    易错 阈值太小日志爆炸(磁盘被拖垮),太大漏网;200ms~1s 是常见起步档,按业务调。

  • auto_explain 模块自动记录慢查询计划

    场景 慢查询是「偶发」的:事后手动 EXPLAIN,它偏偏又快了--你需要案发当时的计划。

    讲解 auto_explain 预载后,超过阈值的语句执行时自动把执行计划写进日志。它抓的是现场:当时的统计信息、当时的缓存状态、当时的计划。偶发慢查询(plan 抖动、冷缓存)只有它抓得住。

    试试
    -- 会话级试开(不用重启):
    set session_preload_libraries = 'auto_explain';
    set auto_explain.log_min_duration = '500ms';
    set auto_explain.log_analyze = on;      -- 真实执行数据(有开销,谨慎)
    
    -- 跑一条慢查询,然后看日志里有完整计划

    易错 log_analyze = on 会给被记录的语句加额外开销,生产常开要评估;先只开 log_min_duration 版本。

  • 优化前必须确认的三件事:数据量、频率、可接受延迟

    场景 三个优化候选摆在面前,先做哪个?不是哪个慢做哪个。

    讲解 数据量:多大的表、返回多少行;② 频率:一天一次的报表还是每秒 100 次的接口;③ 可接受延迟:报表 1 分钟无妨,交易接口 200ms 是红线。优先级 = 频率 × 收益空间。每天跑一次的 30 秒报表,优化的性价比是零。

    试试
    -- 频率证据:这条查询被调了多少次
    select calls, round(total_exec_time::numeric) as 总耗时ms
    from pg_stat_statements
    order by calls desc limit 10;

    易错 只按「绝对耗时」排优先级是错的:一条 5 秒但每天一次的查询,排不进前三优先级。

练 · 50 min
  1. 把慢日志阈值设为 200ms 并重载配置
  2. 故意跑几条慢查询,从日志里捞出来
  3. 开启 auto_explain,看它记录的计划
  4. 画出属于你自己的排查流程图(一页纸)
  5. 写下「优化前必须先确认的 3 个问题」
过关有一张能对着面试官讲的排查流程图。
专注视图 ->
剧情 运营后台的订单列表翻到第 5000 页直接超时。你治好了自己的 OFFSET 恐惧症。
学 · 40 min
  • SELECT * 的三项代价

    场景 深分页优化的第一步不是改分页,是先把 select * 扫掉。

    讲解 传输:列表页要 20 行 8 列,select * 把整行几十列全传;② 回表:所有列都要回堆取,Index Only Scan 的机会被掐死;③ 扩展性:表加一列,这个接口的流量悄悄变大。只取需要的列是成本最低的优化。

    试试
    -- 反面
    select * from orders order by created_at desc offset 100000 limit 20;
    -- 正面
    select id, user_id, status, total_amount, created_at
    from orders order by created_at desc offset 100000 limit 20;

    易错 select * 还会破坏 prepare 语句的缓存效果(结构一变计划全重算)。

  • 谓词下推与提前过滤:能早过滤就别晚过滤

    场景 「先 join 宽表再过滤」和「先过滤再 join」结果一样、速度差十倍。

    讲解 数据越早变小,后面每一步越便宜:过滤条件尽量推到扫描层(走索引),聚合前先收窄范围。深分页的「延迟关联」就是它的应用:第一步只在索引里拿 20 个 id(窄而快),第二步再拿 id 回表取全部列。

    试试
    -- 延迟关联:先 id 后详情
    select o.*
    from (
      select id from orders
      where user_id = '...'
      order by created_at desc
      offset 100000 limit 20        -- 只在索引层翻页
    ) page
    join orders o on o.id = page.id;   -- 20 次回表

    易错 外层的排序和过滤条件别丢:内层不排序,外层顺序就是乱的。

  • OFFSET 深分页为什么越翻越慢(要先扫过并丢弃前 N 行)

    场景 第 1 页 5ms,第 5000 页 8 秒--OFFSET 的工作方式决定了它。

    讲解 offset 100000 limit 20 的执行 = 扫过前 100000 行、全部丢掉,再取 20 行。翻得越深,丢弃越多,耗时线性增长。OFFSET 的本意是「跳过 N 行」,而跳过也是要一行行数过去的。

    试试
    -- 耗时曲线实验
    \timing on
    select id from orders order by id offset 0       limit 20;
    select id from orders order by id offset 10000   limit 20;
    select id from orders order by id offset 500000  limit 20;
    -- 记录三档耗时,画出来就是一条上升直线

    易错 愿意翻到 5000 页的多半是爬虫不是用户--限页深 + 返回条数上限,本身就是防爬设计。

  • keyset 分页(游标分页)的写法与限制

    场景 治本方案:让「下一页」的代价和「第一页」一样。

    讲解 记住上一页最后一行的排序键,下一页从它接着取:where (排序键) < :last_value order by 排序键 desc limit 20。无论第几页,都只扫 20 行。排序键必须唯一且有序(复合排序加 id 兜底),否则边界行会漏或重。

    试试
    -- 下一页:拿着上一页最后的 (created_at, id) 来
    select id, created_at, total_amount
    from orders
    where (created_at, id) < (:last_created_at, :last_id)   -- 行比较语法
    order by created_at desc, id desc
    limit 20;

    易错 限制要主动说:不能随机跳页(只支持上一页 / 下一页)、排序键中途不能变。产品要跳页就和「延迟关联」组合用。

  • 精确 count 的替代方案

    场景 列表页要显示「共 1,234,567 条」--这个数字本身就要扫几秒。

    讲解 PG 的 count(*) 要真数(MVCC 下不能用索引元数据偷懒),百万级秒起。替代:① pg_class.reltuples 估算(毫秒级,误差百分之几);② 计数表 + 触发器维护(精确、有写放大);③ 前端展示「约 123 万条」。绝大多数列表页,用户根本不在乎精确总数。

    试试
    -- 估算版(毫秒)
    select reltuples::bigint from pg_class where relname = 'orders';
    -- 精确版(慢)
    select count(*) from orders;

    易错 reltuples 依赖统计新鲜度(D41);号称「精确」的功能页(对账、结算)别用估算。

练 · 65 min
  1. OFFSET 0 / 10000 / 500000 各跑一次,记录耗时曲线
  2. 把同一分页改写成 keyset 分页(WHERE id < :last_id ORDER BY id DESC LIMIT 20
  3. 用延迟关联优化:先取 id 再回表取详情
  4. pg_class.reltuples 做近似总数,对比精确 count 的耗时
  5. 做一张三种分页方案的对比表(耗时、能否跳页、适用场景)
过关能写出 keyset 分页 SQL,并主动说出它的限制(不能随机跳页)。
专注视图 ->
剧情 两年攒下的订单里 90% 是不会再查的历史数据。你把 orders 按月分区,最老的分区直接归档下线。
学 · 45 min
  • 声明式分区:RANGE / LIST / HASH 三种

    场景 两年 24 个月的订单挤在一张表里--按月切开后,每月一张「小表」。

    讲解 PG 10+ 的声明式分区:父表带 partition by range/list/hash,子表带 for values ... 定义归属。range 按值区间(时间分区本命);list 按枚举值(按城市、按租户);hash 散列均衡(无明显查询边界时摊数据)。对查询和 DML 而言父子表是一张表,写入自动路由到子分区。

    试试
    create table orders_p (
      id uuid not null,
      created_at timestamp not null,
      ...
      primary key (id, created_at)          -- 分区键必须进主键
    ) partition by range (created_at);
    
    create table orders_p_2026_08 partition of orders_p
      for values from ('2026-08-01') to ('2026-09-01');

    易错 分区键必须是主键 / 唯一键的一部分--「按月分区但主键只有 id」建不起来,这是第一个坑。

  • 分区裁剪的触发条件与验证方法

    场景 分区表凭什么快:查询条件包含分区键时,别的分区碰都不碰

    讲解 「分区裁剪」= 优化器按 WHERE 里的分区键排除无关分区。常量条件在计划期裁剪(explain 直接只列一个分区);「参数化条件」要到执行期裁剪(计划里显示全部分区但实际只扫一个,看 analyze 的 actual)。验证永远用 explain analyze 的 actual 行数。

    试试
    explain select count(*) from orders_p
    where created_at >= '2026-08-01' and created_at < '2026-09-01';
    -- Subplans Only / 只出现一个分区 = 裁剪生效
    
    -- 条件不含分区键 = 全分区扫(24 个分区全摸一遍)
    explain select count(*) from orders_p where total_amount > 1000;

    易错 对分区键套函数(date_trunc(created_at))会让裁剪失效--和索引失效同一个道理。

  • 分区键选择原则,以及分区带来的代价

    场景 「该不该分区、按什么分」的决策框架。

    讲解 选键原则:高频查询条件里出现的那一列(时间是最常见的)。代价照单全收:① 跨分区查询 / 不含分区键的查询反而变慢;② 全局唯一约束做不了(唯一键必须含分区键);③ 分区键定了一般改不了;④ 运维复杂度(几百个子表的管理)。索引能解决的性能问题不要用分区解决

    试试
    -- 分区数量也要克制:几百个分区后规划开销开始可感知
    select count(*) from pg_inherits
    where inhparent = 'orders_p'::regclass;

    易错 为了「显得专业」上分区、按 hash 分 16 区但查询从不带分区键 = 白白放大了复杂度。

  • CREATE INDEX CONCURRENTLY 不锁写

    场景 大促前要在百万行表上补索引--普通建索引期间全表写入被锁。

    讲解 普通 create index 拿排他锁,建完才放行写入;concurrently 分两趟扫表、不阻塞 DML,代价是耗时更长、不能在事务里跑。生产大表加索引的默认姿势。

    试试
    -- 普通版:锁写直到完成
    create index idx_created on orders_p (created_at);
    
    -- 并发版:写入不受阻(另开窗口验证)
    create index concurrently idx_created on orders_p (created_at);
    -- 别忘了:不能包在 begin...commit 里

    易错 concurrently 中途失败会留下 invalid 索引(占空间不可用),要 drop 掉重建:查 pg_index 的 indisvalid 列。

  • 大批量更新为什么要分批提交

    场景 一条 UPDATE 把 200 万行历史数据打标--事务日志爆炸、复制延迟、长锁。

    讲解 单个巨型事务的三重罪:WAL 洪峰(磁盘和复制跟不上)、锁持有全程(别人排队)、失败回滚代价同样是巨型。分批:每批 1 万行、循环提交,每批影响可控,失败从断点重跑。注意批间会有别的事务插进来,过滤条件要幂等(每批重新筛选还没处理的行)。

    试试
    do $$
    declare n int := 1;
    begin
      while n > 0 loop
        withas (
          select id from orders_p
          where status is null
          limit 10000
          for update skip locked
        )
        update orders_p o
        set status = 0
        fromwhere.id = o.id;
        get diagnostics n = row_count;   -- 没得改了就停
      end loop;
    end $$;

    易错 「一次更新所有行」的失败重试等于再来一遍全量;分批 + 幂等筛选才有断点续跑能力。

练 · 60 min
  1. 把 orders 按月做 RANGE 分区,迁移数据
  2. 用 EXPLAIN 验证带时间条件的查询只扫了一个分区
  3. 用 CONCURRENTLY 在大表上建索引,另开窗口验证写入不被阻塞
  4. 写一个每次更新 1 万行、循环提交的批量更新脚本
  5. 把最老的一个分区 DETACH 出来归档
过关能说出分区的两个好处和两个代价(如跨分区查询变慢、分区键不能随便改)。
专注视图 ->
剧情 老板天天开的后台仪表盘每次都实时算全量报表,把库拖慢了。你用物化视图提速,顺手给运营开了只读账号。
学 · 40 min
  • 视图不存数据、物化视图存数据

    场景 老板的仪表盘每次打开都实时聚合百万行--先把「视图」和「物化视图」分清楚。

    讲解 视图 = 存起来的一条查询,每次 select 都重新执行(不省任何计算,价值在封装和安全);物化视图 = 查询结果落盘,查它就是查表,毫秒级。代价:数据是快照,必须定期 REFRESH 才更新。仪表盘这种「容忍分钟级延迟、查询巨重」的场景是物化视图的本命。

    试试
    create view v_monthly as
    select date_trunc('month', created_at) as 月份, count(*), sum(total_amount)
    from orders group by 1;                    -- 每次都实时算
    
    create materialized view mv_monthly as
    select date_trunc('month', created_at) as 月份, count(*), sum(total_amount)
    from orders group by 1;                    -- 算一次存下来
    
    refresh materialized view mv_monthly;      -- 手动更新快照

    易错 视图改名/换定义不影响底表;但「以为视图能提速」是最常见误解--它是查询的别名,不是缓存。

  • REFRESH MATERIALIZED VIEW CONCURRENTLY 需要唯一索引

    场景 普通 REFRESH 期间仪表盘直接查询报错/排队--大促时这不能忍。

    讲解 普通 refresh 拿排他锁:刷新期间读也被挡。concurrently 版对比新旧结果、增量替换,刷新期间可读。代价和前提各一:刷新本身更慢;物化视图上必须有唯一索引

    试试
    create unique index on mv_monthly (月份);   -- 前提
    
    refresh materialized view concurrently mv_monthly;
    -- 另一个窗口此刻查询 mv_monthly 不受阻塞

    易错 没有唯一索引时用 concurrently 直接报错--这是「为什么刷新锁表」排查时最先查的一项。

  • 物化视图的刷新代价与数据新鲜度取舍

    场景 刷新多勤?这是个业务问题不是技术问题。

    讲解 刷新 = 全量(或增量)重算:表越大刷新越贵,还占双倍空间(新旧快照切换)。决策框架:业务能容忍多旧的数据?分钟级延迟换十倍查询提速,绝大多数报表都愿意。定时刷新(pg_cron / crontab)+ 查询时标注「数据截至 HH:MM」是标准交付形态。

    试试
    -- 看物化视图多大、上次刷新何时(自己维护一列刷新时间也行)
    select relname, pg_size_pretty(pg_relation_size(relname::regclass))
    from pg_class where relname = 'mv_monthly';

    易错 对「必须实时」的数据上物化视图是方向错误--那该做的是优化查询本身或上缓存层。

  • 角色、GRANT 与最小权限原则

    场景 运营要查数据,总不能把超级用户密码给他。

    讲解 create role readonly nologin 建角色,grant select on 表 to readonly 授权,再把登录账号(role login + 密码)加进角色。最小权限原则:每个账号的权限刚好够干自己的活、多一点都不给--运营只读,分析只读几张表,写入只走应用账号。

    试试
    create role readonly nologin;
    grant usage on schema public to readonly;
    grant select on all tables in schema public to readonly;
    
    create role ops_report login password '...';
    grant readonly to ops_report;    -- 运营账号继承只读权限
    
    -- 验证越权:用 ops_report 连上后
    -- update orders set status = 1;   -- ERROR: permission denied

    易错 新表不会自动继承 grant(除非 alter default privileges)--「加了张表运营看不到」先查这个。

  • 行级安全(RLS)简介

    场景 「每个用户只能看自己的订单」--不用改任何查询,数据库层强制。

    讲解 RLS 给表挂策略:alter table ... enable row level security 开关 + create policy ... using (...) 定义哪些行可见。开启后,普通角色查这张表自动被策略过滤(比如 user_id = current_setting(...))。多租户 SaaS 的标配防线。

    试试
    alter table orders enable row level security;
    
    create policy own_orders on orders
      using (user_id = current_setting('app.user_id')::uuid);
    -- 会话里 set app.user_id = '...' 后,select 自动只见自己的单

    易错 表的 owner 默认绕过 RLS--要真拦住自己得 force row level security;应用连接千万别用 owner 账号。

练 · 65 min
  1. 把 D13 的月报查询建成普通视图
  2. 改成物化视图,对比两者查询耗时
  3. 加唯一索引后用 CONCURRENTLY 刷新,验证刷新期间可读
  4. 建一个只读角色,授予部分表的 SELECT 权限并测试越权访问
  5. 给 orders 加一条 RLS 策略,让「用户」只能看自己的订单
过关能说清物化视图的适用场景,以及它带来的数据延迟问题怎么权衡。
专注视图 ->
剧情 大促前的代码评审,你把这一年里见过的反模式整理成清单,替后端把了关。
学 · 45 min
  • N+1 查询、SELECT *、大事务、无限制 IN 列表

    场景 后端代码评审,四大常客一个不落。

    讲解 N+1:取 100 个订单再循环逐个查用户 = 101 次往返,改成 join 或 where id = any(数组) 一次取回;② select *(D44 讲过三宗罪);③ 大事务:事务里夹外部 HTTP 调用,锁陪你等超时;④ 无限制 IN 列表:塞一万个 id 进 in (...),解析慢、计划烂,改 values join / 临时表 / 数组。

    试试
    -- N+1 的解药:一次取回
    select o.*, u.name from orders o join users u on u.id = o.user_id
    where o.id = any($1::uuid[]);    -- 数组参数版,替代 in (一万个字面量)

    易错 N+1 的识别:应用日志里短时间海量相同模板语句;ORM 的懒加载是重灾区,列表场景要显式预取。

  • 隐式类型转换、软删除导致的表膨胀

    场景 另外两个慢性病:一个杀索引,一个悄悄膨胀。

    讲解 隐式转换:varchar 列 = 整数,索引失效(D41);② 软删除:全表 update 置 is_deleted,看似温柔实则:死元组暴涨(vacuum 压力)、表和索引双膨胀、每条查询都要带过滤条件(漏写就是事故)。历史数据定期归档硬删(D45 的分区 detach)才是正解。

    试试
    -- 软删除的膨胀体检
    select n_live_tup, n_dead_tup,
           round(100.0 * n_dead_tup / nullif(n_live_tup, 0), 1) as 死活比
    from pg_stat_user_tables where relname = 'orders';

    易错 软删除不是免费开关:它的账单(膨胀 + 遗忘过滤条件 + 索引全量变大)往往在半年后才寄到。

  • count(*) 慢的成因与近似方案

    场景 「为什么 PG 的 count 这么慢,MySQL 不是挺快?」--考点。

    讲解 PG 的 count 必须逐行检查可见性(MVCC:每行版本对不同事务可见性不同,索引里没有这个信息),所以再好的索引也只能加速定位、不能免检。MySQL 的 MyISAM 引擎表头存了行数所以快,但那是「不支持事务的快」,InnoDB 同样要数。方案:reltuples 估算 / 计数表 / 业务上接受「约 N 条」(D44 详述)。

    试试
    explain analyze select count(*) from orders;
    -- 计划再优也是全量可见性检查:这就是它的成本下限

    易错 「MySQL count 快 PG 慢所以 PG 不行」是拿古董引擎(MyISAM)比的错觉,面试要能拆穿。

  • EAV 模型的代价、把数据库当消息队列的取舍

    场景 评审里最「聪明」的两个设计,往往最贵。

    讲解 EAV(实体-属性-值三列表):想存任意自定义属性。代价:查一个完整对象要 pivot 出几十个 join、类型约束丢失(value 列只能用 text)、无法建外键。真有半结构化需求,PG 的 jsonb 是更体面的答案。② 拿表当队列(状态列 + 轮询):并发抢占有竞态,必须 for update skip locked(D35 的方案);撑得住小规模,量大还是上消息中间件。

    试试
    -- EAV 的日常:想拿「颜色」就得 pivot
    select e.id,
           max(case when a.attr = '颜色' then a.value end) as 颜色,
           max(case when a.attr = '尺寸' then a.value end) as 尺寸
    from entities e join eav a on a.entity_id = e.id
    group by e.id;   -- 属性一多就是灾难现场

    易错 两个模式的共同话术是「灵活」;共同账单是「查询地狱 + 约束真空」。评审时听到「灵活」先警惕。

练 · 50 min
  1. 写出 N+1 的具体现象,并给出 JOIN 或批量查询的改法
  2. 实测三种 count 方案(精确 / reltuples 估算 / 计数表)的耗时
  3. 找出自己这个库里可能存在的 EAV 或软删除膨胀
  4. IN 列表塞 1 万个 id,观察计划与耗时,改成 VALUES join 或临时表
  5. 整理成 antipatterns.md,每条含「为什么坏 + 怎么改」
过关反模式清单至少 8 条,每条都能说出改法。
专注视图 ->
剧情 大促压测结果:1000 个并发抢 100 件库存,超卖 37 件。你要用三种武器把超卖清零。
学 · 45 min
  • 用唯一索引 + 幂等键实现幂等写入

    场景 同一用户连点五次「抢购」按钮,只能成交一件--第一道防线。

    讲解 把「同一件事」定义成唯一键(用户 + 活动 / 商品),建唯一约束 + on conflict do nothing。重复请求在数据库层被无声吞掉,第二次起的插入根本不生效。这是所有幂等设计里最便宜、最可靠的一档:不依赖任何应用层逻辑。

    试试
    create unique index uq_seckill on seckill_orders (user_id, activity_id);
    
    insert into seckill_orders(user_id, activity_id, ...)
    values (:uid, :aid, ...)
    on conflict (user_id, activity_id) do nothing
    returning id;   -- 有返回 = 抢到,无返回 = 重复请求

    易错 幂等键的颗粒度是业务定义的:用户+活动?用户+商品+秒?定义错颗粒度,要么吞单要么重复。

  • 乐观锁:版本号 / CAS 更新与重试策略

    场景 「读-改-写」三步之间数据被人改了怎么办--提交时校验,冲突就重试。

    讲解 update ... set x = 新值, version = version + 1 where id = ? and version = 旧version:影响行数为 0 说明被抢先,应用层重读再试。没有锁等待、吞吐高,适合冲突少的场景;冲突率高时重试风暴反而拖垮吞吐。

    试试
    -- 先读:select stock, version from products where id = 1;  -- version=5
    update products
    set stock = stock - 1, version = version + 1
    where id = 1 and version = 5;      -- CAS
    -- 返回 0 行 = 被别人抢先改了:重读、重算、重试(应用层循环)

    易错 CAS 的 where 条件是「旧值」不是「目标值」--写成 stock >= 0 就变成了下一条的原子扣减,语义混了。

  • 悲观锁:SELECT ... FOR UPDATE

    场景 复杂的多步「读-算-写」,一步锁住,别人排队。

    讲解 事务里先 select ... for update 把行锁住,读到的值保证不被别人动,算完再 update、commit 放锁。绝对正确、逻辑最直白;代价是串行化:后到的全部排队,吞吐由事务长度决定。

    试试
    begin;
    select stock from products where id = 1 for update;   -- 锁住
    -- 应用判断 stock > 0
    update products set stock = stock - 1 where id = 1;
    commit;   -- 放锁,下一个排队者进来

    易错 锁持有时间 = 整个事务长度:锁住行之后再调外部接口、发短信,就是把全队列按住陪等。

  • 原子扣减:UPDATE ... SET stock = stock - 1 WHERE stock >= 1

    场景 其实大多数「防超卖」只需要这一条语句。

    讲解 把「判断 + 扣减」压进一条 UPDATEset stock = stock - 1 where id = ? and stock >= 1。单语句的原子性由数据库保证,不为 0 就扣不成负。影响行数 0 = 没抢到,应用直接处理失败分支。没有读-改-写间隙、没有竞态窗口。

    试试
    update products
    set stock = stock - 1
    where id = 1 and stock >= 1
    returning stock;    -- 返回新库存 = 成功;无返回 = 售罄

    易错 条件是 stock >= 1 而不是应用层先判断--判断放应用层,间隙里库存就被别人扣了(这就是当初超卖 37 件的机制)。

  • 三种方案的并发吞吐与失败率差异

    场景 三把武器都造好了,压测台上见真章。

    讲解 原子扣减:单字段扣减的最优解,无重试无等待,吞吐最高;② 悲观锁:多步业务逻辑(扣库存 + 建订单 + 记流水)必须串行时的正确解,吞吐受事务长度限制;③ 乐观锁:冲突稀疏时近于无锁,冲突密集时重试风暴。选型口诀:简单扣减用原子,复杂流转用悲观,冲突少才乐观。

    试试
    -- 压测脚本骨架:pgbench 或多会话并发
    -- 每个会话循环执行:
    update products set stock = stock - 1
    where id = 1 and stock >= 1;
    -- 记录三种方案下:成功数 / 失败数 / 平均耗时 / 超卖数(必须 = 0)

    易错 别为「显得高级」上乐观锁重试框架--简单场景一条原子 UPDATE 完胜,工程判断力恰恰体现在克制。

练 · 60 min
  1. 用唯一索引 + ON CONFLICT DO NOTHING 实现幂等下单
  2. 用版本号乐观锁扣库存,模拟冲突后重试
  3. FOR UPDATE 悲观锁扣库存
  4. 用原子条件更新扣库存,验证不会扣成负数
  5. pgbench 或多个会话并发压测,对比三种方案的成功率与耗时
过关能讲清三种防超卖方案各自的适用场景和代价。
专注视图 ->
剧情 大促平稳收官。你把 W6–W7 的优化经历写成文档--这将是简历上最硬的一条。
文档结构
  • 背景 -> 现象 -> 定位过程 -> 计划对比 -> 方案 -> 结果数据 -> 反思
  • 关键:每一步都要有数字证据
练 · 120 min
  1. 把 D42 的三条优化整理成一份完整案例文档
  2. 贴上优化前后的执行计划截图或文本
  3. 写出「为什么这个方案,而不是另一个方案」的取舍
  4. 写一段 3 分钟的口头版本,录音听一遍
  5. 提炼成简历上的一句话(带数字)
过关文档能独立看懂;口头版本 3 分钟内讲完且每句都经得起追问。
专注视图 ->