你的 Diesel 迁移可能是定时炸弹
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 锁,在索引构建期间阻止所有写操作(INSERT、UPDATE、DELETE)。在拥有数百万行的表上,这可能需要数分钟。
添加 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 的锁队列是 先到先服务。如果迁移在等待锁,所有后续查询都会排在它后面,即使它们与原始锁持有者不冲突。
典型场景:
- 一个长时间运行的查询在
users表上持有ACCESS SHARE锁。 - 迁移尝试获取
ACCESS EXCLUSIVE并排队。 - 一个新的
SELECT到来;它本来可以使用ACCESS SHARE,但因为迁移在等待,这个SELECT被放到队列的后面。 - 更多的
SELECT到来,也一起排队。 - 应用开始超时。
迁移甚至还没有开始,你已经宕机了。
为什么很难发现这个问题
这些迁移在开发环境(数百行)看起来正常,在预发布环境(数万行)瞬间完成。但在生产环境(数百万行)会锁表数分钟,阻塞流量。直到在真实数据和真实流量下运行时,你才会发现问题。
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 还是自行构建检查,在迁移进入生产环境之前让某些工具审查它们都是值得的。这些模式已有充分文档记录,你只需要相应的工具来强制执行即可。
在数据库迁移工作流中构建安全措施是值得的。