你的 Diesel 迁移可能是定时炸弹

发布: (2025年12月16日 GMT+8 22:03)
11 分钟阅读
原文: Dev.to

Source: Dev.to

Postgres 锁问题

Handshake 在他们的日常多次发布周期中部署了看似例行的迁移:

ALTER TABLE table_name
  ADD CONSTRAINT fk_user_id
  FOREIGN KEY (user_id) REFERENCES users(id);

60 秒后,整个站点宕机。

问题是什么? 添加外键约束需要对被引用的表获取 ACCESS EXCLUSIVE 锁。PostgreSQL 按 先到先得 的顺序授予锁。users 表上有一个长时间运行的查询持有 ACCESS SHARE 锁,导致迁移在等待该锁时排队。由于 ACCESS EXCLUSIVE 与所有锁冲突,随后到来的所有普通 SELECT 查询也被迫排在它后面。锁队列不断增长,站点停止响应,最终只能中止迁移才能恢复站点。

GoCardless 也遇到了类似的问题。他们在重命名的表上重新创建外键约束。虽然这些表是空的,看起来很安全,但添加约束仍然需要对父表加锁,而父表正被大量使用。结果导致 API 超时,持续约 15 秒。

这两个事件都源于看似完全正常的数据库迁移,这些迁移在预演环境中运行良好,却只有在生产环境中才暴露出真正的问题。

Source:

常见会获取 ACCESS EXCLUSIVE 锁的操作

PostgreSQL 使用不同的锁级别来保证数据的一致性。最严格的锁是 ACCESS EXCLUSIVE。当某个操作持有此锁时,任何其他操作都无法触及该表——既不能 SELECT,也不能 INSERT,什么都不行。

许多常见的迁移操作会获取 ACCESS EXCLUSIVE 锁:

创建索引

CREATE INDEX idx_users_email ON users(email);

这会获取 SHARE 锁,在索引构建期间阻止所有写操作(INSERTUPDATEDELETE)。在拥有数百万行的表上,这可能需要数分钟。

添加 NOT NULL

ALTER TABLE users ALTER COLUMN email SET NOT NULL;

PostgreSQL 必须检查每一行是否都有非空值,在整个检查过程中会持有 ACCESS EXCLUSIVE 锁。

更改列类型

ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(10,2);

这会触发完整的表重写——每一行都要转换为新类型——并在此期间持有 ACCESS EXCLUSIVE 锁。

锁队列让情况更糟

PostgreSQL 的锁队列是 先到先服务。如果迁移在等待锁,所有后续查询都会排在它后面,即使它们与原始锁持有者不冲突。

典型场景:

  1. 一个长时间运行的查询在 users 表上持有 ACCESS SHARE 锁。
  2. 迁移尝试获取 ACCESS EXCLUSIVE 并排队。
  3. 一个新的 SELECT 到来;它本来可以使用 ACCESS SHARE,但因为迁移在等待,这个 SELECT 被放到队列的后面。
  4. 更多的 SELECT 到来,也一起排队。
  5. 应用开始超时。

迁移甚至还没有开始,你已经宕机了。

为什么很难发现这个问题

这些迁移在开发环境(数百行)看起来正常,在预发布环境(数万行)瞬间完成。但在生产环境(数百万行)会锁表数分钟,阻塞流量。直到在真实数据和真实流量下运行时,你才会发现问题。

Source:

进入 diesel‑guard

diesel-guard 是一个静态分析工具,用于扫描你的 Diesel 迁移文件,检测危险操作。

安装

cargo install diesel-guard

运行

diesel-guard check migrations/

示例输出

❌ 在 migrations/2024_01_01_add_fk/up.sql 中检测到不安全的迁移

❌ ADD COLUMN 带 DEFAULT

问题:
  在 PostgreSQL < 11 上向表 'orders' 添加带 DEFAULT 的列会导致完整的
  表重写,并获取 ACCESS EXCLUSIVE 锁。对于大表来说,这可能需要
  相当长的时间并阻塞所有操作。

安全的替代方案:
  1. 添加不带默认值的列:
     ALTER TABLE orders ADD COLUMN status TEXT;

  2. 批量回填数据(在迁移之外):
     UPDATE orders SET status = 'pending' WHERE status IS NULL;

  3. 为新行设置默认值:
     ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'pending';

  注意: 对于 PostgreSQL 11 及以上版本,如果默认值是常量,则此操作是安全的。

该工具会告诉你:

  • 操作的危险性
  • 它会持有什么锁
  • 一步步的安全修复方案(包含 SQL)

安全的常用操作替代方案

创建索引

而不是

CREATE INDEX idx_orders_created_at ON orders(created_at);

请使用

CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders(created_at);

CONCURRENTLY 允许在索引构建期间继续写入。由于 CREATE INDEX CONCURRENTLY 不能在事务中运行,需要添加 metadata.toml 文件:

# migrations/2024_01_01_add_order_index/metadata.toml
run_in_transaction = false

添加 NOT NULL

而不是

ALTER TABLE users ALTER COLUMN email SET NOT NULL;

请使用

-- 添加一个不验证现有行的 CHECK 约束
ALTER TABLE users
  ADD CONSTRAINT users_email_not_null_check
  CHECK (email IS NOT NULL) NOT VALID;

-- 单独验证(锁更轻)
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null_check;

-- 最后,如有需要,删除 CHECK 并添加 NOT NULL 约束
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

初始的 CHECK … NOT VALID 只获取轻量级锁,随后可以分批执行 VALIDATE CONSTRAINT,以避免长时间的排他锁。

添加 NOT NULL 约束

-- Add a check constraint (fast since we validated)
ALTER TABLE users ADD CONSTRAINT users_email_not_null_check CHECK (email IS NOT NULL);

-- Add NOT NULL (fast since we validated)
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- Clean up
ALTER TABLE users DROP CONSTRAINT users_email_not_null_check;

VALIDATE 步骤使用 SHARE UPDATE EXCLUSIVE,它允许读取和写入继续进行。

添加 UNIQUE 约束

而不是:

ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);

这样做:

-- 并发创建索引
CREATE UNIQUE INDEX CONCURRENTLY users_email_idx ON users(email);

-- 使用已有索引添加约束(即时)
ALTER TABLE users
  ADD CONSTRAINT users_email_key
  UNIQUE USING INDEX users_email_idx;

添加外键

而不是:

ALTER TABLE posts
  ADD CONSTRAINT posts_user_id_fkey
  FOREIGN KEY (user_id) REFERENCES users(id);

请这样做:

-- 添加约束但不验证已有行
ALTER TABLE posts
  ADD CONSTRAINT posts_user_id_fkey
  FOREIGN KEY (user_id) REFERENCES users(id)
  NOT VALID;

-- 单独验证(锁更轻)
ALTER TABLE posts VALIDATE CONSTRAINT posts_user_id_fkey;

NOT VALID 表示不扫描已有行。VALIDATE 在单独的步骤中执行,使用的锁更轻。

设置超时

Handshake 和 GoCardless 都学到的一件事:在迁移中设置 lock_timeout

-- At the top of your migration
SET lock_timeout = '2s';

ALTER TABLE users ADD COLUMN email TEXT;

如果迁移在 2 秒内无法获取锁,它将失败,而不是无限排队。你的应用保持运行,你可以在流量较低时重试。

当你确信安全时

有时你知道迁移是安全的(小表、维护窗口等):

-- safety-assured:start
-- Safe because: table has 50 rows, deploying during maintenance window
ALTER TABLE countries ADD COLUMN flag_emoji TEXT DEFAULT '🏳️';
-- safety-assured:end

diesel-guard 将跳过这些块中的任何内容。

配置

在你的项目中放置一个 diesel-guard.toml

# Skip checking migrations before this date
start_after = "2024_01_01_000000"

# Check down.sql too
check_down = true

# Disable specific checks if needed
disable_checks = ["CreateExtensionCheck"]

CI/CD 集成

将以下内容添加到 GitHub Actions:

name: Check Migrations
on: [pull_request]

jobs:
  check-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ayarotsky/diesel-guard@v0.3.0

现在,危险的迁移会在 PR 审核阶段被捕获,而不是在生产环境中。

检查内容

  • ADD COLUMN with DEFAULT
  • CREATE INDEX without CONCURRENTLY
  • ADD NOT NULL
  • ADD UNIQUE constraint
  • ADD FOREIGN KEY without NOT VALID
  • ADD PRIMARY KEY to existing tables
  • ADD SERIAL columns
  • ALTER COLUMN TYPE
  • CREATE EXTENSION
  • DROP COLUMN
  • DROP INDEX without CONCURRENTLY
  • DROP PRIMARY KEY
  • RENAME COLUMN
  • RENAME TABLE
  • Short integer primary keys (SMALLINT/INT)
  • TRUNCATE TABLE
  • Unnamed constraints
  • Using JSON instead of JSONB
  • Wide indexes (4+ columns)

更多检查即将加入。目标是覆盖大多数危险 PostgreSQL 操作的 40 项检查。

为什么这对 Rust 很重要

Rust 生态系统拥有出色的工具链。clippy 对你的代码进行 lint 检查。cargo audit 捕获安全问题。但我们没有针对数据库迁移的工具。

我见过太多因迁移导致的生产事故。事后修复通常很明显,但只有在导致宕机时才会发现。

diesel-guard 将修复提前到开发阶段。

我应该使用它吗?

也许你在想“我的表很小。”

表会增长。今天只有 100 行的 users 表,明年可能会有一百万行。现在瞬间完成的迁移,届时可能需要几分钟。

从一开始就构建安全的迁移要比在事故中修复它们容易得多。而且 diesel-guard 只需要几秒钟即可运行。

总结

数据库迁移可能很棘手。看似完全安全的操作可能导致严重的生产问题。它们在预演环境和生产环境中的行为差异可能非常大,往往要等到为时已晚才发现。

静态分析可以提前捕获这些问题。无论是使用 diesel-guard 还是自行构建检查,在迁移进入生产环境之前让某些工具审查它们都是值得的。这些模式已有充分文档记录,你只需要相应的工具来强制执行即可。

在数据库迁移工作流中构建安全措施是值得的。

Back to Blog

相关文章

阅读更多 »