本节目标:把数据访问层剩下的三个难题一次讲透——表结构怎么改才不锁库、并发写入怎么写才不留半成品、连接池该配多大才不会把数据库压垮。读完后你能设计一条可回滚的迁移流水线,写出一段经得起并发与死锁考验的事务,并说清连接数为什么要用公式算而不是拍脑袋。
7.3 迁移、事务与连接池
前两节解决的是「怎么读写数据」,但生产环境的故障几乎都出在这三件事上:一次没想清楚的 ALTER TABLE 把表锁了十分钟;一个在事务里发 HTTP 请求的写法让连接池被占满;max_connections 配成 500 之后数据库开始频繁上下文切换。这一节逐个拆解。
三件事的边界
先分清职责,避免把它们混成一锅:
| 问题 | 本质 | 出错的表现 |
|---|---|---|
| 迁移 | 结构变更与代码发布的时序 | 老代码读新表、新代码读老表 |
| 事务 | 并发写入的正确性边界 | 部分成功、脏读、死锁 |
| 连接池 | 有限资源的分配 | 请求排队、超时、数据库过载 |
它们共享同一个前提:数据库是共享的、有状态的、会阻塞的。任何「本地跑得好好的」写法,都要重新用这个前提审视一遍。
迁移:expand-contract 模式
迁移的核心矛盾是:代码可以灰度、可以回滚,数据库结构变更却往往是单向的。解法是把一次破坏性变更拆成两个兼容的变更,中间夹着代码切换——这就是 expand-contract。
以「把 users.name 拆成 first_name 和 last_name」为例,完整流程是四个阶段:
- Expand(扩展):只加新列,允许为空,不删旧列。此时老代码照常读写
name,新列闲置。 - Backfill(回填):分批把旧数据搬进新列,每批之间留间隔,避免长事务与 WAL 膨胀。
- Migrate(切换):发布新代码,双写新旧列、读新列,老列变成只写不读。
- Contract(收缩):确认没有回滚需求后,删掉旧列与双写逻辑。
这四步之间每一步都可以停,且任何一步停下时新旧代码都能工作。这是「零停机」的全部秘密,展开的做法见 零停机数据库迁移策略 。
各类 DDL 的真实锁行为
很多人以为「加个列而已」,实际行为差别很大。以 PostgreSQL 为例:
| 操作 | 锁级别 | 是否重写表 | 注意 |
|---|---|---|---|
ADD COLUMN(无默认值) | ACCESS EXCLUSIVE,瞬时 | 否 | 快,但抢锁可能排队 |
ADD COLUMN ... DEFAULT <常量> | ACCESS EXCLUSIVE,瞬时 | 否(PG 11+) | 常量默认值走元数据 |
ADD COLUMN ... DEFAULT <易变函数> | ACCESS EXCLUSIVE | 是 | 全表重写,大表必炸 |
ALTER COLUMN ... SET NOT NULL | ACCESS EXCLUSIVE | 全表扫描 | 用 CHECK NOT VALID 替代 |
ALTER COLUMN ... TYPE | ACCESS EXCLUSIVE | 通常重写 | 兼容转换也可能重写 |
CREATE INDEX | SHARE,阻塞写 | — | 大表用 CONCURRENTLY |
DROP COLUMN | ACCESS EXCLUSIVE,瞬时 | 否 | 只改元数据,空间稍后回收 |
ACCESS EXCLUSIVE 锁会阻塞所有读写,包括 SELECT。所以生产环境执行 DDL 前,第一件事是设置锁等待上限,宁可失败重试也不要拖垮在线流量:
SET lock_timeout = '3s';
ALTER TABLE users ADD COLUMN first_name text;
配合重试循环,这条语句会在拿不到锁时快速失败,几秒后再试,而不会把后面的查询全部堵住。锁竞争的排查方法见 PostgreSQL 锁竞争分析 。
另外 CREATE INDEX CONCURRENTLY 有一个硬性限制:它不能在事务里执行。Prisma 的 migrate 和 Drizzle 的 migrate 都会把迁移文件包在事务里,所以这类语句必须单独处理——要么用 prisma migrate dev --create-only 生成文件后手工拆成独立迁移并加注释跳过事务,要么在迁移文件里显式禁用事务包装。忘了这一点会得到:
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
迁移文件进版本库、进 CI
无论用 Prisma 还是 Drizzle,迁移文件都必须提交,并且生产环境只允许应用已有迁移,不允许生成新迁移:
pnpm prisma migrate deploy
这条命令的语义是「把未应用的迁移按序执行完」,它不比对 schema、不生成文件、不提示交互。生产发布流水线里只应该出现它。完整的分阶段发布与灰度流程见 18.2 数据库迁移与灰度发布 ,流水线本身的搭建见 数据库迁移流水线 。
回滚策略要说清楚一件事:迁移回滚不等于代码回滚。如果迁移删了列,回滚代码也找不回数据。所以真正可靠的策略是「结构变更向前兼容 + 数据备份」,而不是指望 migrate resolve 回退版本号。备份与恢复演练见 数据库备份恢复策略
。
事务:正确性的边界在哪
事务要解决的是「一组操作要么全成功、要么全失败」,但真正难的是并发下的可见性。
隔离级别与它解决的问题
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | PostgreSQL 实现 |
|---|---|---|---|---|
| Read Uncommitted | 可能 | 可能 | 可能 | 等同 Read Committed |
| Read Committed | 否 | 可能 | 可能 | 默认级别 |
| Repeatable Read | 否 | 否 | 否 | 快照隔离 |
| Serializable | 否 | 否 | 否 | SSI,可能串行化失败 |
PostgreSQL 的默认级别是 Read Committed,很多人以为它足够安全,其实「读-改-写」模式在这里是会丢更新的。经典的计数器写法:
// 危险:两个并发请求可能都读到 100,最终只加了一次
const user = await tx.user.findUnique({ where: { id } })
await tx.user.update({ where: { id }, data: { points: user.points + 10 } })
正确写法有三种,按推荐顺序:
// 方案一:原子更新,把计算交给数据库
await tx.user.update({ where: { id }, data: { points: { increment: 10 } } })
// 方案二:悲观锁,把行锁到事务结束
const rows = await tx.$queryRaw<{ points: number }[]>`
SELECT points FROM users WHERE id = ${id} FOR UPDATE
`
await tx.user.update({ where: { id }, data: { points: rows[0].points + 10 } })
// 方案三:乐观锁,用版本号检测冲突
const res = await tx.user.updateMany({
where: { id, version: expectedVersion },
data: { points: newPoints, version: { increment: 1 } },
})
if (res.count === 0) throw new ConflictError('数据已被他人修改')
increment 之所以安全,是因为它编译成 SET points = points + 10,由数据库在行锁保护下完成。只要表达得出,优先用方案一。MVCC 的实现细节与隔离级别的边界见 事务隔离与并发控制
。
交互式事务与批量事务
Prisma 提供两种形态。批量事务适合「几条语句一起提交」:
await prisma.$transaction([
prisma.order.create({ data: order }),
prisma.inventory.update({ where: { sku }, data: { stock: { decrement: 1 } } }),
])
交互式事务适合「后面的语句依赖前面的结果」:
await prisma.$transaction(
async (tx) => {
const order = await tx.order.create({ data: order })
await tx.inventory.update({ where: { sku }, data: { stock: { decrement: 1 } } })
return order
},
{ maxWait: 2_000, timeout: 5_000, isolationLevel: 'RepeatableRead' },
)
timeout 是整个事务的墙钟上限,maxWait 是等待连接池空位的时间。两个都必须显式设置:默认值在生产环境往往太长,一个卡住的事务会长时间占着连接。Drizzle 的形态类似:
await db.transaction(
async (tx) => {
const [order] = await tx.insert(orders).values(order).returning()
await tx.update(inventory).set({ stock: sql`${inventory.stock} - 1` }).where(eq(inventory.sku, sku))
return order
},
{ isolationLevel: 'repeatable read' },
)
事务里绝对不能做的事
三条铁律,违反任何一条都会在压力下暴露:
- 不要在事务里做网络调用。 调用第三方支付、发消息队列、写对象存储——这些操作的耗时不可控,而事务持有连接与行锁。一个 3 秒的超时意味着这 3 秒内该行不可写。
- 不要吞掉异常。
try { ... } catch {}会让 Prisma 认为事务正常结束并提交,只写入了一半的数据。需要部分失败时,应该捕获后重新抛出,或者用 savepoint。 - 不要在事务里做重活。 批量计算、生成报表、压缩图片都应该在事务外完成,事务里只做写入。
死锁与串行化失败的重试
即使写得很小心,死锁仍会发生。PostgreSQL 检测到死锁会杀掉其中一个事务并报错:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 54321.
另一个常见的可重试错误是 Serializable 隔离级别下的串行化失败:
ERROR: could not serialize access due to concurrent update (SQLSTATE 40001)
两者都应该用「指数退避 + 抖动」重试,而不是直接失败:
const RETRYABLE = new Set(['40001', '40P01'])
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn()
} catch (err) {
const code = (err as { code?: string }).code
if (i >= attempts - 1 || !code || !RETRYABLE.has(code)) throw err
const backoff = 2 ** i * 50 + Math.random() * 50
await new Promise((r) => setTimeout(r, backoff))
}
}
}
关键点是整个事务必须重跑,不能只重试失败的语句——因为事务已经被数据库中止。这要求事务体写成幂等的纯函数,副作用(发消息、调外部 API)放到提交之后。幂等与重试的完整讨论见 9.2 重试、幂等与死信 。
事务之外的最终一致性:outbox
「写库」和「发消息」这两个动作天然无法放进同一个本地事务。如果先提交事务再发消息,中间崩溃就丢消息;如果先发消息再提交,事务回滚就发了不该发的消息。
标准解法是 outbox 表:把待发消息和业务数据写在同一个事务里,再由后台任务轮询投递。
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: order })
await tx.outbox.create({
data: { topic: 'order.created', payload: { orderId: order.id }, status: 'PENDING' },
})
return order
})
投递器读 status = 'PENDING' 的行,投递成功后标记 SENT。这条链路把「至少一次投递」的责任转移给了轮询任务,业务事务里只保留数据库操作。消费端必须幂等,做法见 分布式幂等与可靠性
。
连接池:为什么不能拍脑袋配
每个数据库连接在后端都对应一个进程(PostgreSQL)与一块内存,且上下文切换有成本。连接不是越多越好——超过某个点后吞吐反而下降。业界常用的起点公式是:
连接数 ≈ CPU 核心数 × 2 + 磁盘主轴数
一台 8 核 SSD 机器,合理的总连接数大约在 20 上下,而不是 200。注意这是整个应用集群的总数,不是单个实例的数。若你有 10 个 Pod,每个 Pod 的连接池上限就该是 2 左右,而不是各配 20。深入分析与实测方法见 数据库连接池优化 。
在 ORM 里配置池
Prisma 通过连接串参数控制:
postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=20&connect_timeout=5
connection_limit:该 PrismaClient 实例的最大连接数,默认是物理核数 × 2 + 1。pool_timeout:等待空闲连接的秒数,超时抛P2024。connect_timeout:建立 TCP 与认证的秒数。
P2024 是连接池相关最常见的报错,它的含义是「池里没空位了」,而不是「数据库连不上」:
Error: P2024 Timed out fetching a new connection from the connection pool.
(Current connection pool timeout: 20, connection limit: 10)
看到它时应该先查「有没有事务忘记提交」和「有没有慢查询占着连接」,而不是直接把 connection_limit 调大——调大只会把压力推给数据库。
Drizzle 用的是驱动自身的池,postgres.js 直接传 max:
const client = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 5,
})
PgBouncer 与预编译语句的冲突
连接数一多,通常会在应用与数据库之间加一层 PgBouncer。但 PgBouncer 的 transaction 模式会在事务结束后把连接交还给其他客户端,而预编译语句是绑定在连接上的——于是出现:
ERROR: prepared statement "s0" already exists
处理方式有两种。Prisma 在连接串加 pgbouncer=true 让它放弃命名预编译语句;postgres.js 则显式关闭:
const client = postgres(url, { prepare: false })
注意代价:关闭预编译意味着每次查询都要重新解析与计划,高频小查询的延迟会上升。PgBouncer 的部署与模式选择见 PgBouncer 连接池实践 。
无服务器环境:连接是稀缺品
Serverless 函数会横向扩到几百个实例,每个实例建一个连接就是灾难。此时有三条路:
| 方案 | 做法 | 代价 |
|---|---|---|
| 外部连接池 | 走 PgBouncer / 云厂商连接代理 | 多一跳网络 |
| 驱动适配器 | Prisma driver adapters + 边缘连接池 | 需要改造客户端 |
| 按需短连接 | 每次请求新建连接 | 握手开销大,只适合低频 |
无论哪条路,都要给数据库设一道保险:idle_in_transaction_session_timeout 会强制回收「开着事务却闲置」的连接,是防止连接泄漏的最后防线:
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
ALTER SYSTEM SET statement_timeout = '10s';
statement_timeout 则给所有查询加一个上限,避免某条漏加 LIMIT 的查询拖垮整个实例。
三者交叉处的坑
把三个话题放在一起看,会出现一些单看任何一个都想不到的问题:
- 长事务 × 迁移:迁移需要
ACCESS EXCLUSIVE锁,而长事务持有旧快照。结果是迁移排队、后面所有查询排队,整库雪崩。所以迁移前要确认没有长事务,且必须设lock_timeout。 - 连接池 × 迁移:迁移期间连接可能被服务端重启而断开。应用侧要有重连逻辑,且发布顺序应该是「先迁移、后发代码」,而不是相反。
- 事务 × 连接池 × 超时:
pool_timeout与事务timeout必须协同。如果事务timeout是 5 秒而pool_timeout是 20 秒,会出现「已经等不到连接了但事务还在超时计时」的错乱日志。 - 缓存 × 事务:事务里更新了数据库却忘了失效缓存,会读到旧值。缓存的键设计与失效策略见 8.1 缓存层次与键设计 。
观测:看不见就等于没有
以上所有策略都需要可观测性支撑。至少要采集三类信号:
| 信号 | 来源 | 告警阈值示例 |
|---|---|---|
| 连接使用率 | pg_stat_activity 计数 / 池的 active | 持续 > 80% |
| 慢查询 | pg_stat_statements | p99 > 200ms |
| 锁等待 | pg_locks 中 granted = false | 存在超过 5s 的等待 |
SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state;
更细的查询级追踪应该接到应用侧的链路追踪上,让一次慢请求能直接定位到具体 SQL,做法见 数据库查询可观测性 与 慢查询调优 。服务本身的优雅关闭也相关:关闭时要先把连接池排空,否则重启期间会丢请求,见 5.3 优雅关闭与健康检查 。
本章收束
到这里,数据访问层的三块拼图凑齐了:7.1 的 Prisma 提供了从 schema 到类型的自动化,7.2 的 Drizzle 提供了贴近 SQL 的推导能力,本节补齐了两者共有的运维难题。选哪个 ORM 影响的是写法,而迁移、事务、连接池这三件事不管你选谁都得面对,它们才是数据访问层真正的工程门槛。
下一章我们向上走一层:数据读出来了,怎么用缓存挡住热点流量。
小结
这一节我们把数据访问层的运维面收束成三条主线:
- 迁移用 expand-contract 拆成扩展、回填、切换、收缩四步,每一步都可停;DDL 前必须设
lock_timeout,CREATE INDEX CONCURRENTLY不能进事务。 - 事务的默认隔离级别是 Read Committed,读-改-写会丢更新,优先用
increment这类原子更新;死锁与40001要整事务重试,网络调用与吞异常是三条铁律里最常被违反的两条;跨系统一致性用 outbox。 - 连接池的大小用「核心数 × 2 + 主轴数」估算,
P2024说明池被占满而不是数据库不可达;上 PgBouncer 要处理预编译语句冲突,idle_in_transaction_session_timeout是防泄漏的最后防线。 - 三者交叉时会互相放大故障:长事务会让迁移排队并引发整库雪崩,超时参数必须协同设置。
- 没有观测就没有优化——连接使用率、慢查询、锁等待三类信号是这套体系的最低配置。
数据读得又快又稳之后,下一步是让它更便宜。下一章我们从缓存层次与键设计开始,讲清「什么时候不该查数据库」。
阅读导航:上一节:7.2 Drizzle 的 SQL 式类型推导 · 下一节:8.1 缓存层次与键设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。