《TypeScript编程实战》7.3 迁移、事务与连接池

本节收束数据访问层剩下的三个难题:表结构怎么改才不锁库、并发写入怎么写才不留半成品、连接池该配多大才不会把数据库压垮。先讲 expand-contract 迁移模式与加列改列的真实锁行为,再讲交互式事务、隔离级别、死锁重试与 outbox,最后给出连接数估算公式、PgBouncer 与预编译语句的冲突,以及慢查询与连接指标的观测手段。

本节目标:把数据访问层剩下的三个难题一次讲透——表结构怎么改才不锁库、并发写入怎么写才不留半成品、连接池该配多大才不会把数据库压垮。读完后你能设计一条可回滚的迁移流水线,写出一段经得起并发与死锁考验的事务,并说清连接数为什么要用公式算而不是拍脑袋。

7.3 迁移、事务与连接池

前两节解决的是「怎么读写数据」,但生产环境的故障几乎都出在这三件事上:一次没想清楚的 ALTER TABLE 把表锁了十分钟;一个在事务里发 HTTP 请求的写法让连接池被占满;max_connections 配成 500 之后数据库开始频繁上下文切换。这一节逐个拆解。

三件事的边界

先分清职责,避免把它们混成一锅:

问题本质出错的表现
迁移结构变更与代码发布的时序老代码读新表、新代码读老表
事务并发写入的正确性边界部分成功、脏读、死锁
连接池有限资源的分配请求排队、超时、数据库过载

它们共享同一个前提:数据库是共享的、有状态的、会阻塞的。任何「本地跑得好好的」写法,都要重新用这个前提审视一遍。

迁移:expand-contract 模式

迁移的核心矛盾是:代码可以灰度、可以回滚,数据库结构变更却往往是单向的。解法是把一次破坏性变更拆成两个兼容的变更,中间夹着代码切换——这就是 expand-contract。

以「把 users.name 拆成 first_name 和 last_name」为例,完整流程是四个阶段:

  1. Expand(扩展):只加新列,允许为空,不删旧列。此时老代码照常读写 name,新列闲置。
  2. Backfill(回填):分批把旧数据搬进新列,每批之间留间隔,避免长事务与 WAL 膨胀。
  3. Migrate(切换):发布新代码,双写新旧列、读新列,老列变成只写不读。
  4. Contract(收缩):确认没有回滚需求后,删掉旧列与双写逻辑。

这四步之间每一步都可以停,且任何一步停下时新旧代码都能工作。这是「零停机」的全部秘密,展开的做法见 零停机数据库迁移策略 。

各类 DDL 的真实锁行为

很多人以为「加个列而已」,实际行为差别很大。以 PostgreSQL 为例:

操作锁级别是否重写表注意
ADD COLUMN(无默认值)ACCESS EXCLUSIVE,瞬时否快,但抢锁可能排队
ADD COLUMN ... DEFAULT <常量>ACCESS EXCLUSIVE,瞬时否(PG 11+)常量默认值走元数据
ADD COLUMN ... DEFAULT <易变函数>ACCESS EXCLUSIVE是全表重写,大表必炸
ALTER COLUMN ... SET NOT NULLACCESS EXCLUSIVE全表扫描用 CHECK NOT VALID 替代
ALTER COLUMN ... TYPEACCESS EXCLUSIVE通常重写兼容转换也可能重写
CREATE INDEXSHARE,阻塞写—大表用 CONCURRENTLY
DROP COLUMNACCESS 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' },
)

事务里绝对不能做的事

三条铁律,违反任何一条都会在压力下暴露:

  1. 不要在事务里做网络调用。 调用第三方支付、发消息队列、写对象存储——这些操作的耗时不可控,而事务持有连接与行锁。一个 3 秒的超时意味着这 3 秒内该行不可写。
  2. 不要吞掉异常。 try { ... } catch {} 会让 Prisma 认为事务正常结束并提交,只写入了一半的数据。需要部分失败时,应该捕获后重新抛出,或者用 savepoint。
  3. 不要在事务里做重活。 批量计算、生成报表、压缩图片都应该在事务外完成,事务里只做写入。

死锁与串行化失败的重试

即使写得很小心,死锁仍会发生。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_statementsp99 > 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 缓存层次与键设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes