技术债清点:DDL 重整与列类型

复盘一个月攒下的债:status 靠裸字符串、有人问你金额能不能用 float、id 会不会溢出。今天重整 DDL,把类型的坑一次踩明白。

学 45 min
练 60 min
盘 15 min
共 120 分钟

学 · 45 min

  1. 01CREATE / ALTER / DROP TABLE;大表 ALTER 加列什么时候会长时间锁表

    场景线上 250 万行的 order_items 要加一列,操作不当全站写入卡死。

    关键在「要不要重写整张表」:加列带 DEFAULT(PG 11+)只改元数据,秒回;varchar(20) 放宽到 varchar(50) 不重写,很快;收窄长度或跨类型转换(varchar 改 int)要全表重写,期间持锁。动手前先在测试库跑一遍看耗时。

    alter table order_items add column note text default '';          -- 秒回
    alter table order_items alter column note type varchar(50);    -- 不重写,快
    alter table order_items alter column note type integer;        -- 全表重写,锁到跑完

    易错「加列带默认值会锁表」是 PG 10 及更早的老黄历;但「改类型」的雷是真的--生产 DDL 前必须测耗时。

  2. 02主键选型:bigserial vs GENERATED ALWAYS AS IDENTITY vs uuid

    场景新建 payments 表:主键用自增还是 uuid?这不是口味题。

    bigserial 是老语法(本质是个宏:建序列 + 默认值);identity 是 SQL 标准(PG 10+),语义更严--always 版阻止手动插 id,细节不受干扰;uuid 全局唯一,多库合并、分布式生成不冲突。练习库 orders 用的 uuid(导出文件就是无序编号),payments 可以用 identity。

    create table payments (
      id bigint generated always as identity primary key,
      ...
    );

    易错serial 不是一种「类型」--面试官爱问这个。新项目默认 identity,需要跨系统唯一才上 uuid。

  3. 03uuid 主键的两个代价:索引膨胀与随机写放大

    场景后端说 uuid 好看又安全,全表都上--先看账单。

    索引膨胀:uuid 是 16 字节,bigint 是 8 字节,索引天然大一倍;② 随机写放大:uuid 无序,新行落在 B+ 树随机位置,频繁页分裂、缓存局部性差。写入量大的表,uuid 索引的插入明显慢。缓解:uuidv7(时间前缀、单调递增)。

    易错面试答「uuid 有什么问题」只说「占空间」不够--写放大和页分裂才是核心。

  4. 04金额为什么必须 numeric;timestamptz 存的到底是什么;text vs varchar(n)

    场景三个最高频的类型问题一次结清。

    ① 金额:numeric 是精确十进制,float 是二进制近似(0.1 存不准);② timestamptz 磁盘上存的是 UTC 时刻(自 2000-01-01 的微秒数),时区只是显示层的换算;③ PG 的 text 无长度上限,varchar(n) 有硬上限(超长报错不截断)--真要限制长度,text + check(length(x) <= n) 更灵活。

    select 0.1::float8 + 0.2::float8,      -- 0.30000000000000004
           0.1::numeric + 0.2::numeric;      -- 0.3
    
    show timezone;      -- 只影响显示,不影响存储
    select now() at time zone 'Asia/Shanghai';

    易错「timestamptz 存的是带时区的时间」是常见错答--存的是 UTC 时刻,时区是会话显示参数。

  5. 05jsonb / enum / 数组类型各自适用场景;生成列

    场景商品的扩展属性老变、状态值固定、标签一对多但懒得建表--三种「不走寻常路」的类型。

    jsonb:半结构化数据(扩展属性、接口报文),可 GIN 索引查询;enum:值集固定不变的状态(可读性好),加值要 ALTER 类型;数组:一对多的轻量场景(标签、权限位)。生成列 generated always as (表达式) stored:写入时自动算好存盘,读取零成本。

    create table order_items (
      ...,
      line_total numeric generated always as (qty * unit_price) stored
    );
    insert into order_items(order_id, product_id, qty, unit_price)
    values (1, 1, 3, 9.90);   -- line_total 自动 = 29.70

    易错enum 加值容易删值难(删要重排);值集将来会变就用 text + check。jsonb 滥用会把「无 schema 的灵活」变成「无 schema 的泥潭」。

练 · 60 min

  1. select 0.1::float8 + 0.2::float8 与 numeric 版对比,记下结果
    参考答案

    float 是二进制近似:0.1 在磁盘上就不是 0.1,误差随累加放大,对账永远差几分钱;numeric 是十进制精确存储。钱的类型只有 numeric 一个答案。

    select 0.1::float8 + 0.2::float8 as float,    -- 0.30000000000000004
           0.1::numeric + 0.2::numeric as numeric;  -- 0.3
  2. 用 IDENTITY 重建一张表,对比 serial 的差异
    参考答案

    \d 两张表长得一样(都是序列供默认值);差异在语义:identity 是 SQL 标准,always 版拦手动插 id,serial 只是「建序列 + 设默认值」的旧语法宏。

    create table demo_serial (id bigserial primary key, val text);
    create table demo_identity (id bigint generated always as identity primary key, val text);
    
    insert into demo_serial(val) values ('a');
    insert into demo_identity(val) values ('a');   -- 日常用法没区别
    
    insert into demo_serial(id, val) values (100, '手动插');    -- serial 不拦
    insert into demo_identity(id, val) values (100, '手动插');
    -- ERROR: cannot insert a non-DEFAULT value into column "id" ...
    -- 除非显式写 overriding system value
    
    drop table demo_serial, demo_identity;   -- 练习完清理
  3. 给 order_items 加生成列 line_total = qty * unit_price
    参考答案

    生成列写入时算好存盘、读取零成本;表达式必须是 immutable(乘法没问题)。对比「每次查询现算 sum」:典型的用写成本换读成本。

    -- 加生成列:存量行会现场算好,12 万行要几秒(这是会重写表的操作)
    alter table order_items
      add column line_total numeric generated always as (qty * unit_price) stored;
    
    select order_id, qty, unit_price, line_total
    from order_items
    limit 5;
    
    -- 顺手对账:明细合计应等于 total_amount,对不上的 ≈100 单正是埋的负金额脏数据
    select count(*) as 对不上的订单数
    from orders o
    join (select order_id, sum(line_total) as s
          from order_items group by order_id) t on t.order_id = o.id
    where o.total_amount <> t.s;
  4. 在 12 万行的 order_items 上加一个带 DEFAULT 的列,记录耗时;再把一列 varchar(20) 改成 varchar(50)、改成 int,对比两次耗时
    参考答案

    ①② 是元数据操作所以秒回,③ 要重写 12 万行且期间持锁--§F 冲到 250 万行后同样操作就是分钟级锁。生产 DDL 前先在测试库测耗时,测的就是这个差距。

    \timing on
    
    -- ① 加带 DEFAULT 的列:PG 11+ 只改元数据,毫秒级返回
    alter table order_items add column memo varchar(20) default '';
    
    -- ② varchar(20) 放宽到 varchar(50):不重写表,毫秒级
    alter table order_items alter column memo type varchar(50);
    
    -- ③ 改成 int:全表重写 + 逐行转换,肉眼可见地慢
    update order_items set memo = '0';   -- 空串转不了 int,先换成数字文本
    alter table order_items alter column memo type integer using memo::integer;
    
    alter table order_items drop column memo;   -- 还原
  5. pg_dump -s 导出整库结构存档
    参考答案

    -s 只导结构不含数据。这份存档是今天 DDL 重整的「验收快照」:之后每次改表重新导一份,diff 一眼看出改了什么。

    docker exec pg16 pg_dump -U postgres -s shop > shop_schema.sql
    -- 本机装了客户端的话也可以:
    pg_dump -h localhost -U postgres -s shop > shop_schema.sql
过关标准 能说出 uuid 做主键的两个具体代价、金额不能用 float 的原因、timestamptz 磁盘上存的是什么。