数据库变更完全指南:从一次"线上加个字段、整张表锁了十分钟、服务全挂"看懂在线 DDL

2022 年我做一个用户系统要给 users 表加一个字段。第一版我做得很省事既然要改表结构那就直接在生产库上敲一条 ALTER TABLE。本地我拿一张几百行的小表测了测秒过。我心里很踏实改表结构嘛就是直接 ALTER TABLE 一下数据库自己会搞定。可等这条语句真正跑到线上落在那张几千万行的大表上一串问题冒了出来。第一种最先把我打懵那条 ALTER 跑了整整十分钟这十分钟里 users 表的读写全被卡住依赖它的服务大面积超时整个站点几乎瘫痪。第二种我以为是加的字段太复杂可我加的只是一个普通的整型列后来才知道它在那个版本的 MySQL 上走了 COPY 算法把整张表悄悄拷贝重建了一遍。第三种我学乖了改成半夜低峰期执行可半夜恰好有个长事务在跑我的 ALTER 卡在元数据锁上一动不动而它一边卡后面排队访问这张表的查询全被它堵死雪崩了。第四种最狼狈一次改表中途失败表改了一半我想回滚却发现自己根本没准备回滚脚本只能手忙脚乱地现场抢修。我盯着这一连串问题想了很久才彻底想明白第一版错在我以为改表结构就是直接在生产库上 ALTER TABLE 一下。可它不是。ALTER TABLE 在大表上代价巨大而且极不均匀有的变更瞬间完成有的要原地重建索引有的要把整张表拷贝重建并长时间持锁而且任何 DDL 都要先抢一把元数据锁一个长事务就能把它连同它后面排队的所有请求一起堵死。真正安全地改表核心不是找个半夜去执行 ALTER 而是理解 DDL 的代价分级用在线工具或分步迁移把变更拆成不锁表的小步并让每一步都可回滚。本文从头梳理为什么直接 ALTER 是错的 DDL 的代价怎么分级在线改表工具怎么用 expand-contract 怎么把危险变更拆安全迁移脚本怎么写才可回滚以及长事务外键迁移与发布顺序这些把表变更真正做对要避开的坑。

2022 年我做一个用户系统,要给 users加一个字段。第一版我做得很省事:既然要改表结构,那就直接在生产库上敲一条 ALTER TABLE。本地我拿一张几百行的小表测了测——秒过:语句一回车,字段就加好了。我心里很踏实:"改表结构嘛,就是直接 ALTER TABLE 一下,数据库自己会搞定。"可等这条语句真正跑到线上、落在那张几千万行的大表上,一串问题冒了出来。第一种最先把我打懵:那条 ALTER 跑了整整十分钟——这十分钟里,users 表的读写全被卡住,依赖它的服务大面积超时、整个站点几乎瘫痪。第二种:我以为是加的字段太复杂,可我加的只是一个普通的整型列——后来才知道,它在那个版本的 MySQL 上走了 COPY 算法,把整张表悄悄拷贝重建了一遍。第三种:我学乖了,改成半夜低峰期执行,可半夜恰好有个长事务在跑,我的 ALTER 卡在元数据锁上一动不动,而它一边卡,后面排队访问这张表的查询全被它堵死,雪崩了。第四种最狼狈:一次改表中途失败,表改了一半,我想回滚,却发现自己根本没准备回滚脚本,只能手忙脚乱地现场抢修。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"改表结构,就是直接在生产库上 ALTER TABLE 一下"。这句话把"小表上的瞬间操作"和"大表上的危险手术"当成了一回事。可它不是ALTER TABLE 在大表上代价巨大、而且极不均匀:有的变更瞬间完成,有的要原地重建索引,有的要把整张表拷贝重建并长时间持锁;而且任何 DDL 都要先抢一把元数据锁,一个长事务就能把它、连同它后面排队的所有请求,一起堵死。真正安全地改表,核心不是"找个半夜去执行 ALTER",而是理解 DDL 的代价分级、用在线工具或分步迁移把变更拆成不锁表的小步、并让每一步都可回滚。这篇文章就把数据库的在线变更梳理一遍:为什么"直接 ALTER"是错的、DDL 的代价怎么分级、在线改表工具怎么用、expand-contract 怎么把危险变更拆安全、迁移脚本怎么写才可回滚,以及长事务、外键、迁移与发布顺序这些把表变更真正做对要避开的坑。

问题背景

先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:在几千万行的大表上直接执行 ALTER TABLE 之后,上线冒出一串问题:那条语句跑了十分钟、期间整表读写被阻塞,服务大面积超时;一个看似普通的加列操作,偷偷走了 COPY 算法、把整张表重建了一遍;改到半夜执行,又因为一个长事务占着元数据锁,ALTER 卡死、后面的查询全部排队雪崩;一次改表中途失败、没有回滚脚本,只能现场抢修。

我当时的错误认知:"改表结构,就是直接在生产库上 ALTER TABLE 一下,数据库自己会搞定。"

真相:同样是"改表",代价天差地别。MySQL 8.0 之后,DDL 的执行方式分成几档:INSTANT(只改元数据,瞬间完成)、INPLACE(原地重建,不拷贝全表,一般不阻塞读写)、COPY(把整张表拷贝重建,耗时长、长时间持锁)。你同一句 ADD COLUMN,在不同版本、不同写法下,可能走 INSTANT,也可能走 COPY——而你事先不指定算法,数据库就默默替你选,选中 COPY 你也不知道。更要命的是,任何 DDL 开始前,都要先拿到这张表的 MDL 元数据锁。只要有一个未提交的长事务正在用这张表,你的 ALTER拿不到锁、只能干等;而它一边等,会把后面所有要访问这张表的请求,全都堵在它身后排队。所以安全的改表,不是"挑个低峰期"那么简单,而是要看懂代价分级、用对工具、把变更拆小、并准备好退路

要把数据库的在线变更做对,需要几块认知:

  • 为什么"直接 ALTER"是错的——大表上的 DDL 会锁表、会被长事务堵成雪崩;
  • 看懂 DDL 的代价——INSTANT / INPLACE / COPY 三档,差距是数量级的;
  • 在线改表工具——gh-ost、pt-osc 用"影子表"绕开锁表;
  • expand-contract——把一次危险变更,拆成几步都安全的小变更;
  • 可回滚的迁移脚本、长事务、外键这些工程坑怎么处理。

一、为什么"直接 ALTER TABLE"是错的

先把这件最根本的事钉死:一张表在小的时候,和它长到几千万行之后,是两个完全不同的东西。小表上,ALTER TABLE 几乎瞬间完成,因为要搬动的数据少得可以忽略;大表上,同一条语句可能要把几千万行数据,一行一行地拷贝、重建一遍——这中间的耗时,不是慢一点,而是从"毫秒"变成"十分钟"。而在这十分钟里,如果这条 DDL 走的是会锁表的算法,那么这张表上的所有写操作,全都得在门外排队等它结束。一个站点的核心大表被锁十分钟,基本就等于这个站点停摆十分钟。

下面这段,就是我那个"上线锁表十分钟"的第一版:

-- 反面教材:在一张几千万行的大表上,直接执行 ALTER TABLE
ALTER TABLE users ADD COLUMN level INT NOT NULL DEFAULT 0;

-- 破绽一:这条语句在老版本 MySQL、或某些变更类型下会走 COPY 算法,
--         它会偷偷把整张 users 表拷贝重建一遍 —— 几千万行,要几分钟甚至几十分钟。
-- 破绽二:重建期间,这张表上的写操作 INSERT / UPDATE / DELETE 会被长时间阻塞。
-- 破绽三:DDL 开始前要拿 MDL 元数据锁,只要有一个未提交的长事务在用这张表,
--         ALTER 就会一直等锁;而它一边等,后面所有访问这张表的查询也跟着排队堵死。

这段语句在本地小表上表现完美,因为几百行数据,拷贝重建快得感觉不到。它的问题不在语句本身,而在一个被忽略的前提:它默认"ALTER TABLE 是个轻量、瞬间的操作"。可这个前提,只在小表上成立。于是那串问题就有了解释:锁表十分钟,是因为大表走了 COPY 算法、要重建整表;半夜执行还雪崩,是因为DDL 必须先抢 MDL 锁,被长事务堵住后,它自己又变成了堵住别人的那道墙。问题的根子清楚了:安全改表的工程量,全在"承认 ALTER TABLE 在大表上是一台危险的重型机械"之后——你不去搞清楚它这一下到底会动多少数据、会持多久的锁,就等于闭着眼睛在生产库上做手术。先从看懂这台机械的"挡位"说起。

二、看懂 DDL 的代价:INSTANT、INPLACE、COPY

MySQL 8.0 之后,执行一个 DDL,底层有三种代价完全不同的算法,理解它们,是安全改表的第一课:

  • INSTANT:只改数据字典里的元数据,完全不碰数据行。无论表多大,都是瞬间完成。比如在表末尾加一个列。
  • INPLACE:原地重建(通常是重建索引),不拷贝整张表,大多数情况下不阻塞读写,但仍然要耗时。比如加一个二级索引。
  • COPY:把整张表拷贝、重建一遍耗时最长、长时间持锁,是大表上的头号杀手。比如改一个列的数据类型。

关键在于:你不能靠猜。你要做的,是在执行时显式指定算法,让数据库在执行前就告诉你这次变更有多重:

-- MySQL 8.0:执行前显式指定算法,让数据库提前告诉你这次变更有多重
-- ALGORITHM=INSTANT:只改元数据,瞬间完成,不碰数据行
ALTER TABLE users ADD COLUMN nickname VARCHAR(64), ALGORITHM=INSTANT;

-- 如果这个变更不支持 INSTANT,上面这条会直接报错 —— 而不是默默走更慢的算法。
-- 这样你在执行前就能知道:这次变更到底是"秒级",还是"重建整表"。

-- LOCK=NONE:要求变更期间绝不阻塞读写,做不到就报错
ALTER TABLE users ADD INDEX idx_email (email), ALGORITHM=INPLACE, LOCK=NONE;

显式指定 ALGORITHMLOCK 的意义在于:把"赌"变成"确认"。如果数据库做不到你要求的算法,它会直接报错拒绝,而不是默默降级到 COPY 然后把你的线上锁死。哪些变更属于哪一档,要心里有数:

-- 同样是"改表",代价天差地别,执行前必须分清:
--
-- 【几乎免费 · INSTANT】MySQL 8.0 下只改元数据,瞬间完成:
--   - 在表末尾加一个列
--   - 给列加 / 改默认值
--   - 重命名列
--
-- 【中等 · INPLACE】原地重建,不拷贝全表,一般不阻塞读写,但仍耗时:
--   - 加 / 删一个二级索引
--
-- 【昂贵 · COPY】拷贝重建整张表,耗时长、长时间持锁,大表上的头号杀手:
--   - 改列的数据类型,比如 INT 改成 BIGINT
--   - 改表 / 列的字符集
--   - 老版本 MySQL 上的大部分加列操作

这里的认知要点是:"改表"不是一个动作,而是一组代价差着数量级的动作。同样一句话,选中 INSTANT 是毫秒,选中 COPY 是十分钟——所以改表前的第一件事,永远是先判定这次变更落在哪一档,并用 ALGORITHM 把判断钉死。可问题来了:有些变更(比如改类型、给老版本加列)天生就只能走 COPY,你躲不开。这时候,就需要在线改表工具

三、在线改表工具:用"影子表"绕开锁表

当一个变更无论如何都要走 COPY 时,直接执行就意味着长时间锁表。在线改表工具(gh-ostpt-online-schema-change)解决这个问题的思路非常巧妙,叫"影子表":它不动你的原表,而是在旁边悄悄建一张应用了新结构的"影子表",把数据分小批、限速地搬过去,搬的同时把原表的增量改动也持续追上,等两边追平了,再用一个瞬间的改名完成切换。GitHub 开源的 gh-ost 是这么用的:

# gh-ost:GitHub 开源的在线改表工具,核心思路是"影子表"
# 它不直接 ALTER 原表,而是:
#   1. 建一张和原表结构一样的"影子表",在影子表上做变更
#   2. 把原表的存量数据,分小批、限速地拷进影子表
#   3. 用 binlog 把拷贝期间原表的增量改动,持续追到影子表
#   4. 追平后,用一个瞬间的改名,把影子表换成正式表
gh-ost \
  --host=127.0.0.1 --database=app --table=users \
  --alter="ADD COLUMN level INT NOT NULL DEFAULT 0" \
  --chunk-size=1000 \
  --max-load=Threads_running=50 \
  --execute

这里有两个参数是它"温和"的关键:--chunk-size 让数据一小批一小批地搬,而不是一口气;--max-load 设了一条红线,数据库一旦累到这个程度,gh-ost 就自动暂停搬运、把资源让给线上流量。Percona 工具包里的 pt-online-schema-change 思路一样,区别在怎么追增量:

# pt-online-schema-change:Percona 工具包里的同类工具,思路类似
# 区别在于"怎么追增量":它靠在原表上挂触发器 trigger 来同步,
# 而 gh-ost 是去读 binlog —— gh-ost 对原表更无侵入。
pt-online-schema-change \
  --alter "ADD COLUMN level INT NOT NULL DEFAULT 0" \
  --chunk-size 1000 \
  --max-load Threads_running=50 \
  --critical-load Threads_running=200 \
  --execute \
  D=app,t=users

这里的认知要点是:在线改表工具的本质,是把"一次长时间锁表的重操作",换成"一段长时间但全程不锁表的后台搬运"。它没有让改表变快,而是让改表变得对线上无感——代价是整个过程更久、要占额外的磁盘空间。影子表工具解决的是"单条 DDL 太重",但有些变更的危险不在 DDL 本身,而在它和应用代码的配合——这就要靠 expand-contract

四、expand-contract:把危险变更拆成安全的几步

设想一个变更:你要orders 表的 amount 列改个名、或换个类型。如果你一步到位地改,会撞上一个死结:老版本的应用代码,还在用老列名/老类型;你表一改,老代码立刻就崩;可你又没法让数据库变更和所有应用实例的重启,在同一毫秒发生expand-contract(扩张-收缩)就是拆解这个死结的标准手法:先只做"加法"(expand),让新老结构并存一段时间,等应用平滑切换过去,最后才做"减法"(contract)。第一步,expand——只加一个对老代码完全无害的新列:

-- expand-contract 第一步 expand:只做"加法",且让新结构对老代码完全无害
-- 加一个可空 允许 NULL 的新列 —— 不带 NOT NULL、不带默认值回填,
-- 这样它在 MySQL 8.0 下能走 INSTANT,瞬间完成,老代码完全感知不到它
ALTER TABLE orders ADD COLUMN amount_cents BIGINT NULL, ALGORITHM=INSTANT;

-- 关键:这一步绝不能加 NOT NULL。
-- 一旦加 NOT NULL,数据库就必须为所有存量行立刻填值,变更立刻变重。

新列加好后,应用代码开始"双写"(同时写 amountamount_cents)。接着是回填——把存量的老数据也迁到新列。回填绝不能用一条 UPDATE 扫全表,那等于又锁一次表;必须分小批:

import time
import pymysql

def backfill_amount(conn, batch_size=1000):
    """把存量数据分小批回填进新列,每批之间留出喘息,绝不一条 UPDATE 扫全表。"""
    last_id = 0
    while True:
        with conn.cursor() as cur:
            # 每次只处理一小批,用主键范围圈定,走索引、不锁全表
            cur.execute(
                "SELECT id, amount FROM orders "
                "WHERE id > %s ORDER BY id LIMIT %s",
                (last_id, batch_size),
            )
            rows = cur.fetchall()
            if not rows:
                break
            for row_id, amount in rows:
                cur.execute(
                    "UPDATE orders SET amount_cents = %s WHERE id = %s",
                    (int(amount * 100), row_id),
                )
            last_id = rows[-1][0]
        conn.commit()
        # 关键:每批之间睡一下,把数据库的喘息时间还给线上流量
        time.sleep(0.1)

回填完、双写也稳定运行一段时间后,把读流量切到新列,再观察足够久,确认没有任何代码还依赖老列,最后才做contract——删掉老列:

-- expand-contract 最后一步 contract:确认新列已被双写、已回填、读流量已切过去,
-- 且观察了足够长时间、确认无人再依赖老列之后,才做"减法"
ALTER TABLE orders DROP COLUMN amount, ALGORITHM=INSTANT;

-- 注意 contract 一定要等够观察期再做:
-- 老列一旦删掉就回不去了 —— 删早了,万一要回滚,就没有退路了。

下面这张图,把 expand-contract 的完整流程串起来:

这里的认知要点是:一次"原子"的危险变更,之所以危险,是因为它要求数据库和所有应用代码在同一瞬间一起切换——而这做不到。expand-contract 的全部智慧,就是用"新老并存的过渡期"换掉那个不可能的"同一瞬间",把一次惊险跳跃,拆成几步都踩得稳的台阶。

五、可回滚:每个迁移都要有"后退键"

开头那个"改到一半失败、却没有回滚脚本"的狼狈,根子是我把迁移只当成了"前进"。可线上变更一定会有失败:语句写错、数据不符合预期、变更引发了没料到的性能问题……一个没有回滚方案的迁移,就是一条没有刹车的下坡路。所以每一个迁移,都要成对地写 up(应用)和 down(回滚):

# 每一个迁移,都要成对地写 up 应用 和 down 回滚 两个方向
# 没有 down 的迁移,等于一条没有刹车的下坡路

class AddLevelColumn:
    """给 users 表加 level 列 —— 一个可前进、也可后退的迁移。"""

    version = "20260522_add_user_level"

    def up(self, db):
        # 前进:加列。用可空列加 INSTANT,保证这一步本身就是安全的
        db.execute(
            "ALTER TABLE users ADD COLUMN level INT NULL, ALGORITHM=INSTANT"
        )

    def down(self, db):
        # 后退:把 up 做过的事,精确地、对称地撤销掉
        db.execute("ALTER TABLE users DROP COLUMN level, ALGORITHM=INSTANT")

但要注意:不是所有变更都能用一条 down 简单回退删列、删表这种"减法"操作,一旦执行,数据就没了,down 也救不回来——这正是第四节强调"contract 要等够观察期"的原因。除了改结构,批量改数据(回填、清理历史数据)同样要分批,而且要带上对数据库负载的感知:

def safe_batch_delete(conn, before_ts, batch_size=500):
    """分批删除历史数据:每批之间检查数据库负载,过载就主动让路。"""
    while True:
        # 先看一眼数据库累不累,太累就停下来等
        with conn.cursor() as cur:
            cur.execute("SHOW GLOBAL STATUS LIKE 'Threads_running'")
            running = int(cur.fetchone()[1])
        if running > 80:
            time.sleep(1.0)          # 负载高,让路给线上流量
            continue

        with conn.cursor() as cur:
            cur.execute(
                "DELETE FROM logs WHERE created_at < %s LIMIT %s",
                (before_ts, batch_size),
            )
            affected = cur.rowcount
        conn.commit()
        if affected == 0:
            break
        time.sleep(0.2)             # 每批之间喘息一下

这里的认知要点是:线上变更必须按"它一定会失败"来设计。这意味着两件事:一是结构变更要成对写 up 和 down,让你随时能退回去;二是凡是"减法"操作(删列、删数据),都要靠"先观察、后执行"来争取退路,因为它们一旦做了就真的回不去。结构和回滚都想清楚了,还剩几个最容易被忽略的工程坑。

六、工程坑:长事务、外键与迁移顺序

五块设计之外,还有几个工程坑,不处理就会让一次"看起来很安全"的变更翻车坑 1:执行 DDL 前,先杀掉/避开长事务。这是开头第三个问题的真凶。任何 DDL 都要抢 MDL 锁,一个长事务就能把它和它身后的请求一起堵死。所以执行 DDL 之前,务必先查一眼有没有长事务挡在前面:

-- 执行任何 DDL 之前,先查一眼有没有"危险的长事务"挡在前面
-- 一个跑了很久还没提交的事务,会让你的 ALTER 卡在 MDL 锁上动弹不得

-- 1. 找出运行超过 60 秒、仍未结束的事务
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;

-- 2. 看谁正持有 / 等待这张表的元数据锁
SELECT * FROM performance_schema.metadata_locks
WHERE OBJECT_NAME = 'users';

坑 2:有外键的表,改起来格外小心。在线改表工具(gh-ost、pt-osc)处理带外键的表时会很麻烦——影子表的改名切换,会和外键约束打架。很多大型项目干脆在应用层维护关联、不用数据库外键,一部分原因就是为了让表结构变更更自由。坑 3:迁移和代码发布的顺序,必须想清楚。expand-contract 之所以能成立,靠的就是严格的顺序:先上 expand 迁移 → 再发能双写的新代码 → 回填 → 切读 → 观察 → 最后上 contract 迁移。这个顺序错一步就出事:比如contract 删列的迁移,赶在"还依赖老列的旧代码"下线之前执行了,旧代码立刻报错坑 4:大变更先在预发库/影子库演练。一个大表的变更到底要跑多久、会吃多少磁盘、会不会触发意外的锁,最可靠的办法是先在一个数据量相近的预发环境上完整跑一遍,拿到真实的耗时和资源消耗数据,再决定线上怎么排期。坑 5:变更前必须有备份,且确认备份可恢复。所有的回滚脚本、所有的分步设计,都是第一道防线;而最后一道防线,永远是一份近期的、并且被验证过"真的能恢复"的备份一个没被恢复演练验证过的备份,只是一份"你以为存在"的安全感。

关键概念速查

概念 / 手段 说明
INSTANT 算法 只改元数据,瞬间完成,不碰数据行,如表末尾加列
INPLACE 算法 原地重建,不拷贝全表,一般不阻塞读写,如加二级索引
COPY 算法 拷贝重建整张表,耗时长、长时间持锁,大表头号杀手
MDL 元数据锁 任何 DDL 执行前都要抢的锁,被长事务堵住会引发雪崩
显式指定算法 用 ALGORITHM 和 LOCK 把判断钉死,做不到就报错而非降级
在线改表工具 gh-ost / pt-osc 用影子表把锁表重操作变成无感后台搬运
expand-contract 先加法让新老并存,过渡后再做减法,拆解原子变更死结
分批回填 用主键范围分小批改数据,每批留喘息,绝不一条语句扫全表
可回滚迁移 每个迁移成对写 up 和 down,减法操作靠观察期争取退路
迁移发布顺序 expand 迁移 → 双写代码 → 回填 → 切读 → 观察 → contract 迁移

避坑清单

  1. ALTER TABLE 在大表上是重型操作,小表的瞬间体验会骗了你。
  2. DDL 分 INSTANT / INPLACE / COPY 三档,代价差着数量级。
  3. 执行变更时显式写 ALGORITHM 和 LOCK,做不到让它报错而非降级。
  4. 改列类型、改字符集这类操作只能走 COPY,大表上要用在线工具。
  5. gh-ost / pt-osc 用影子表,把锁表重操作换成不锁表的后台搬运。
  6. 危险的原子变更用 expand-contract,先加法并存,后减法收尾。
  7. 回填存量数据要分小批、带 sleep,绝不用一条 UPDATE 扫全表。
  8. 每个迁移都成对写 up 和 down,删列删数据一旦做就回不去。
  9. 执行 DDL 前先查长事务,一个长事务就能让 ALTER 雪崩。
  10. 大变更先在预发库演练,变更前确认有一份可恢复的备份。

总结

回头看那串"锁表十分钟、COPY 偷偷重建、长事务雪崩、没有回滚脚本"的问题,以及我后来在表变更上接连踩的坑,最该记住的不是某一个 DDL 参数,而是我动手前那个想当然的判断——"改表结构,就是直接在生产库上 ALTER TABLE 一下"。这句话错在它把"改表"想成了一个均质的、轻量的、瞬间的动作。我以为所有的 ALTER TABLE 都和我本地小表上那一下一样快。可我忽略了:同一条语句,落在不同大小的表上、走不同的算法,代价能从毫秒变成十分钟;我也忽略了,一条 DDL 不是孤立运行的,它要和别的事务抢锁,还要和正在跑的应用代码配合在大表上闭着眼睛敲 ALTER TABLE,不是"改个表"那么简单,而是在一台还在载客的飞机上,直接拆掉一个零件。

所以做数据库的在线变更,真正的工程量不在"写出那条 ALTER 语句"上。那条语句,谁都会写。真正的工程量,在于你要承认"改表是一台有挡位的重型机械",并为它配上一整套安全操作规程:它代价分档,你就先判定这次落在 INSTANT 还是 COPY,并用 ALGORITHM 把判断钉死;它躲不开 COPY,你就用 gh-ost 这类影子表工具,把锁表换成无感的后台搬运;它和应用代码耦合,你就用 expand-contract,把原子变更拆成新老并存的几小步;它一定会有失败,你就给每个迁移配上 down、给每次操作留好观察期和备份。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"直接 ALTER"为什么错,再讲怎么看懂 DDL 的三档代价、怎么用在线工具绕开锁表、怎么用 expand-contract 拆解危险变更、怎么让迁移可回滚,最后是长事务、外键、迁移顺序这几个把表变更做扎实的工程细节。

你会发现,在线改数据库结构,和现实里"给一栋还住着人的大楼做改造"完全相通。一个莽撞的施工队会怎么做?他们觉得改造嘛,把那面承重墙一拆,换上新的不就行了(这就是直接 ALTER 大表)。结果墙一拆,整栋楼瞬间没了支撑,住户全得疏散、楼也险些塌了(这就是锁表十分钟、服务全挂)。而一个专业的施工队怎么做?他们先评估这面墙到底承不承重(这就是判定 DDL 的代价分档);真要动承重墙,他们会先在旁边搭好新的支撑柱,让新旧支撑同时受力一段时间(这就是 expand 阶段的新老并存),等确认新柱子稳稳扛住了,才慢慢拆掉老墙(这就是 contract);整个改造分成很多小步、夜里少扰民的时段做一点(这就是分批与低峰执行);而且他们动工前一定先画好"拆错了怎么补回去"的预案(这就是回滚脚本和备份)。

最后想说,数据库变更做没做对,差距永远不会在"本地小表上 ALTER 一下、秒过"时暴露——本地你的表只有几百行,什么算法都快、没有并发事务来抢锁、改错了大不了重建,你会觉得"改表就是敲一条 ALTER"已经是全部。它只在真实的、有几千万行大表、有持续读写、有长事务偶尔串场的线上环境里才显形。那时候它会用最剧烈的方式给你结账:做不好,你会像我一样,看着一条 ALTER 把核心表锁死十分钟、整个站点跟着停摆,在一次中途失败里发现自己连退路都没有;而做了,你的表结构演进会安静得没有存在感:大变更被 gh-ost 在后台一小批一小批悄悄搬完,危险的改名换类型被 expand-contract 拆成了几步谁都察觉不到的小操作,即使中途出了岔子,一个 down 就能干净地退回去。所以别等"核心表被锁死十分钟"找上门,在你写下那行 ALTER TABLE 的那一刻就该想清楚:我面对的不是一张本地的小表,而是一张还在被线上流量持续读写的大表——它这一下会动多少数据、会持多久的锁、会不会被长事务堵住、改错了能不能退回来,这一道道关,我是不是都替它守住了?这些问题有了答案,你做的才不只是一次"侥幸没出事"的改表,而是一次平稳、可控、随时能回头的安全变更

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

大模型 API 并发完全指南:从一次"开 100 个线程狂调 API、结果全被 429 打回"看懂限流应对

2026-5-22 1:04:36

技术教程

大模型记忆系统完全指南:从一次"AI 助手聊着聊着就忘了前面、还越聊越贵"看懂 Agent 记忆分层

2026-5-22 1:18:11

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索