数据库迁移
状态保存在应用程序之外
负载均衡依赖于一个强有力的假设: 无论使用哪个后端来处理传入的请求,结果都是相同的。
我们在第 3 章中已经讨论过这一点: 为了确保在易出错的环境中实现高可用性,云原生应用程序是无状态的——它们将所有持久化问题委托给外部系统(例如数据库)。
这就是负载均衡有效的原因: 所有后端都与同一个数据库通信,以查询和操作相同的状态。
可以将数据库视为一个巨大的全局变量。我们应用程序的所有副本都会持续访问和修改它。
状态是很难把握的。
部署和迁移
在滚动更新部署期间,应用程序的新旧版本同时处理实时流量。 从另一个角度来看:应用程序的新旧版本同时使用同一个数据库。
为了避免停机,我们需要一个两个版本都能理解的数据库模式。 这对于我们的大多数部署来说都不是问题,但当我们需要改进数据库模式时,就会造成严重的制约。
让我们回到我们最初要做的工作:确认邮件。
为了推进我们确定的实施策略,我们需要按如下方式改进数据库模式:
- 添加一个新表 subscription_tokens;
- 在现有的 subscriptions 表中添加一个新的必填列 status。
让我们回顾一下可能的情况,以便确信我们不可能一次性部署所有确认邮件而不会导致停机。
我们可以先迁移数据库,然后再部署新版本。
这意味着当前版本需要在迁移后的数据库上运行一段时间:我们当前的 POST /subscriptions 实现无法获取 status 字段,它会尝试在未填充 status 字段的情况下向 subscriptions 中插入新行。由于 status 字段被限制为 NOT NULL(即强制),所有插入操作都会失败——在新版本应用程序部署完成之前,我们将无法接受新的订阅者。
这很糟糕。
我们可以先部署新版本,然后再迁移数据库。
结果却截然相反:新版本的应用程序正在旧数据库架构上运行。
当调用 POST /subscriptions 时,它会尝试向 subscriptions 中插入一行 status 字段不存在的行——所有插入操作都会失败,在数据库迁移完成之前,我们将无法接受新的订阅者。
这又一次很糟糕。
多步骤迁移
一次大范围的发布并不能解决问题——我们需要分阶段、分步骤地实现目标。
这种模式与我们在测试驱动开发中看到的有些相似:我们不会同时修改代码和测试——两者中需要有一个保持不变,而另一个则进行修改。
这同样适用于数据库迁移和部署:如果我们想要改进数据库架构,就不能同时更改应用程序的行为。
可以将其视为数据库重构:我们奠定基础是为了构建我们以后需要的行为。
新的必填 Column
让我们首先查看 status column。
第一步: 添加为可选
我们首先要确保应用程序代码稳定。
在数据库端,我们生成一个新的迁移脚本:
sqlx migrate add add_status_to_subscriptions
Creating migrations/20250828120711_add_status_to_subscriptions.sql
我们现在可以编辑迁移脚本,将状态作为可选列添加到订阅中:
ALTER TABLE subscriptions ADD COLUMN status TEXT NULL;
针对本地数据库运行迁移 (SKIP_DOCKER=true ./scripts/init_db.sh): 现在我们可以运行测试套件,以确保代码即使在新的数据库架构下也能正常工作。
测试应该会通过: 继续迁移生产数据库。
步骤 2:开始使用新 Column
状态现已存在: 我们可以开始使用它了!
确切地说,我们可以开始写入状态:每次插入新订阅者时,我们都会将状态设置为已确认。
我们只需要将插入查询从
//! src/routes/subscriptions.rs
// [...]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
// [...]
)
// [...]
}
改为
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: &NewSubscriber) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'confirmed')
"#,
// [...]
)
// [...]
}
测试应该通过 - 将新版本的应用程序部署到生产中。
第三步: 回填并标记为 NOT NULL
最新版本的应用程序确保所有新订阅者的状态信息都会被填充。
为了将状态标记为“非空”,我们只需回填历史记录的值即可:然后我们就可以随意修改该列了。
让我们生成一个新的迁移脚本:
sqlx migrate add make_status_not_null_in_subscriptions
SQL 迁移应该看起来是这样的
-- We wrap the whole migration in a transaction to make sure
-- it succeeds or fails atomically. We will discuss SQL transactions
-- in more details towards the end of this chapter!
-- `sqlx` does not do it automatically for us.
BEGIN;
-- Backfill `status` for historical entries
UPDATE subscriptions
SET status = 'confirmed'
WHERE status IS NULL;
-- Make `status` mandatory
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
COMMIT;
我们可以迁移本地数据库,运行测试套件,然后部署生产数据库。
我们成功了,我们将 status 添加为新的必填列!
一个新表
那么 subscription_tokens 呢? 我们也需要三个步骤吗?
不,其实简单得多:我们在迁移中添加新表,但应用程序会一直忽略它。
然后,我们可以部署一个新版本的应用程序,并使用它启用确认电子邮件。
让我们生成一个新的迁移脚本:
sqlx migrate add create_subscription_tokens_table
Creating migrations/20250828122145_create_subscription_tokens_table.sql
这次迁移与我们为添加 subscriptions 而编写的第一个迁移类似:
-- Create Subscription Tokens Table
CREATE TABLE subscription_tokens(
subscription_token TEXT NOT NULL,
subscriber_id uuid NOT NULL
REFERENCES subscriptions (id),
PRIMARY KEY (subscription_token)
);
请注意这里的细节: subscription_tokens 中的subscriber_id 列是外键。
subscription_tokens 中的每一行都必须在subscriptions 中存在一行,其id字段的值与subscriber_id 相同,否则插入操作会失败。这可以保证所有令牌都附加到合法的订阅者。
再次迁移生产数据库 - 大功告成!