千万级大表如何新增字段

千万级大表如何新增字段

先搞懂核心痛点 ⚠️

千万级大表新增字段的本质问题是:传统 DDL 会触发全表重建 + 长时间锁表,导致业务写入完全阻塞,轻则接口超时,重则服务雪崩。

  • MySQL 5.5 及以前:所有 DDL 全表拷贝 + 全程锁表,千万级数据锁表时间可能几小时
  • MySQL 5.6-5.7:引入 Online DDL,部分场景不锁表但仍需重建表
  • MySQL 8.0+:推出 Instant DDL,真正实现秒级加字段

方案优先级排序(从优到劣)🚀

方案一:MySQL 8.0+ Instant DDL(首选✅)

这是目前大厂最推荐的方案,90% 以上的场景都能覆盖。

  • 核心语法:
sql 复制代码
ALTER TABLE user ADD COLUMN phone VARCHAR(20) DEFAULT '' COMMENT '手机号', ALGORITHM=INSTANT;
  • 关键特性:
  1. 秒级完成:仅修改元数据,不重建表,不拷贝数据
  2. 全程不锁表:DML 操作完全不受影响
  3. 8.0.12 + 支持:加字段到任意位置、设置默认值、修改列默认值
  • 唯一限制:不能添加自增列、不能修改列类型

方案二:MySQL 5.6-5.7 Online DDL(次选)

如果还没升级到 8.0,这是次优解。

  • 核心语法:
sql 复制代码
ALTER TABLE user ADD COLUMN phone VARCHAR(20) DEFAULT '' COMMENT '手机号', ALGORITHM=INPLACE, LOCK=NONE;
  • 关键特性:
  1. 不拷贝全表数据,但会重建聚簇索引
  2. 千万级数据执行时长:几分钟到几十分钟
  3. 执行期间 DML 不阻塞,但会有一定性能损耗
  • 注意:必须显式指定LOCK=NONE,否则 MySQL 可能自动升级为锁表模式

整理了面试真题、每日技术知识点、系统学习路线,都汇总在个人网站 www.javadashen.com,有需要的同学自取。

公众号「Rain的Java大神之路」每天拆一个知识点,陪你悄悄变强,有空来坐坐。


方案三:第三方工具 gh-ost/pt-osc(通用方案)

适用于MySQL 5.5 及以下亿级超大型表,是业界最成熟的无锁 DDL 方案。

  • 推荐工具:gh-ost(GitHub 开源,比 pt-osc 更安全)
  • 核心优势:无触发器、低主从延迟、支持暂停 / 回滚 / 限流
  • 执行命令示例:
shell 复制代码
gh-ost --host=127.0.0.1 --user=root --password=xxx --database=test --table=user \
--alter="ADD COLUMN phone VARCHAR(20) DEFAULT '' COMMENT '手机号'" --execute

方案四:双写 + 数据迁移(终极兜底方案)

适用于不能有任何 downtime的核心业务表,零停机时间。

  • 执行步骤:
  1. 先执行ALTER TABLE user ADD COLUMN phone VARCHAR(20) DEFAULT NULL;(允许 null,秒级完成)
  2. 上线代码双写新旧字段(写入时同时更新 phone 字段)
  3. 编写脚本分批迁移历史数据(每次更新 1000 条,避免锁表)
  4. 校验数据一致性(对比新旧字段值)
  5. 切读新字段,下线旧字段逻辑

各方案核心对比表 📊

方案名称 适用 MySQL 版本 锁表时间 执行时长 复杂度 风险点 推荐指数
Instant DDL 8.0.12+ 0ms 秒级 极低 几乎无 ⭐⭐⭐⭐⭐
Online DDL 5.6-5.7 0ms 分钟级 性能损耗、主从延迟 ⭐⭐⭐⭐
gh-ost 所有版本 毫秒级(仅 rename 时) 分钟 - 小时级 主从延迟、磁盘空间 ⭐⭐⭐
pt-osc 所有版本 毫秒级 分钟 - 小时级 触发器死锁、主从延迟 ⭐⭐
双写迁移 所有版本 0ms 天级 数据不一致

gh-ost 工作原理流程图 🧩

生产环境必做注意事项 ⚠️

  1. 执行前必须备份! 备份!备份!重要的事情说三遍
  2. 避开业务高峰(建议凌晨 2-4 点执行)
  3. 先在测试环境执行,验证时长和性能影响
  4. 大表 DDL 不要加事务,不要用--force强制执行
  5. 执行过程中监控:CPU、IO、连接数、主从延迟
  6. gh-ost 建议设置--max-lag-millis=1000,超过 1 秒自动暂停
  7. 不要一次性加多个字段,尽量合并成一次 DDL

核心代码与技术亮点 ✨

1. MySQL 8.0 Instant DDL 生产级写法(必加显式参数)

sql 复制代码
-- 技术亮点:强制指定INSTANT算法,避免MySQL自动降级为INPLACE/COPY
-- 注意:必须设置默认值,否则新增字段为NULL,不会触发全表更新
ALTER TABLE `user_info` 
ADD COLUMN `wx_openid` VARCHAR(64) DEFAULT '' COMMENT '微信openid',
ADD COLUMN `last_login_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '最后登录时间',
ALGORITHM=INSTANT, LOCK=NONE; -- 显式指定不锁表

2. gh-ost 生产级执行脚本(带限流 + 主从保护)

shell 复制代码
#!/bin/bash
# 技术亮点:
# 1. 主从延迟超过1s自动暂停,避免从库雪崩
# 2. 每批拷贝1000行,控制IO压力
# 3. 支持通过创建/tmp/gh-ost.stop文件随时暂停
# 4. 执行前自动检查磁盘空间和表结构
gh-ost \
--host=10.0.0.10 \
--user=gh_ost_user \
--password=xxx \
--database=user_db \
--table=user_info \
--alter="ADD COLUMN wx_openid VARCHAR(64) DEFAULT '' COMMENT '微信openid'" \
--max-lag-millis=1000 \          # 主从延迟超过1s自动暂停
--chunk-size=1000 \              # 每批拷贝行数
--throttle-control-replicas=10.0.0.11,10.0.0.12 \  # 监控指定从库延迟
--allow-on-master \              # 允许在主库执行
--execute

3. 双写迁移分批更新脚本(核心无锁实现)

java 复制代码
/**
 * 技术亮点:
 * 1. 基于主键范围分批,避免全表扫描和长事务
 * 2. 每批执行后sleep 100ms,控制数据库压力
 * 3. 支持断点续传,失败后从上次位置继续
 * 4. 用UPDATE ... WHERE id BETWEEN ... 避免锁全表
 */
@Component
public class UserFieldMigrator {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    public void migrateWxOpenid() {
        long lastId = 0;
        int batchSize = 1000;
        
        while (true) {
            // 按主键范围查询,性能最优
            String sql = "UPDATE user_info SET wx_openid = '' " +
                        "WHERE id > ? AND id <= ? AND wx_openid IS NULL";
            
            int affected = jdbcTemplate.update(sql, lastId, lastId + batchSize);
            if (affected == 0) break;
            
            lastId += batchSize;
            // 关键:控制更新速度,避免打满数据库IO
            Thread.sleep(100);
        }
    }
}

4. 数据一致性校验脚本

sql 复制代码
-- 技术亮点:先总量校验,再抽样校验,兼顾效率和准确性
-- 1. 总量校验
SELECT COUNT(*) FROM user_info WHERE wx_openid IS NULL; -- 应该返回0

-- 2. 抽样校验(取最新1000条数据)
SELECT id, wx_openid FROM user_info 
ORDER BY id DESC LIMIT 1000;

核心技术难点与解决方案 🎯

技术难点 根本原因 生产级解决方案
Online DDL/gh-ost 执行中主从延迟飙升 主库批量写入产生大量 binlog,从库单线程回放跟不上 1. gh-ost 设置--max-lag-millis=1000自动限流2. MySQL 配置binlog_row_image=MINIMAL减少 binlog 体积3. 避开业务高峰执行
大表 DDL 导致磁盘空间不足 DDL 需要创建临时表,占用与原表相同大小的磁盘空间 1. 执行前预留2 倍原表大小的磁盘空间2. gh-ost 用--chunk-size=500减少临时文件增长速度3. 执行完成后立即删除旧表和临时文件
双写迁移时历史数据被覆盖 迁移过程中用户更新了同一条记录,导致迁移脚本覆盖新值 1. 先上线双写代码,再开始迁移历史数据2. 迁移时只更新wx_openid IS NULL的记录3. 用乐观锁version字段防止并发更新
DDL 执行失败后表损坏 / 数据丢失 传统 DDL 执行失败会留下损坏的临时表,无法自动回滚 1. 优先使用 gh-ost,失败后自动清理影子表2. 永远不要用ALTER TABLE ... FORCE强制执行3. 执行前必须做全量备份
MySQL 5.7 加非空默认值字段全表锁 5.7 不支持 Instant DDL,加非空默认值会触发全表更新 1. 分三步执行: ① 加允许 NULL 的字段(秒级) ② 分批更新历史数据为默认值 ③ 修改字段为 NOT NULL(秒级)
千万级表 DDL 导致连接数暴涨 DDL 执行期间 IO 飙升,查询变慢,连接堆积 1. 执行前关闭慢查询日志和通用日志2. 临时调大innodb_buffer_pool_size3. 监控连接数,超过阈值自动暂停 DDL

真实面试模拟

真实面试模拟

面试官:

同学你好,咱们今天聊个经典的场景设计题。假设我们有一张千万级别的用户表,现在产品那边提了个需求,要给表里新增一个字段。你会怎么处理?直接 ALTER TABLE 能行吗?

候选人:

面试官你好。千万级大表直接 ALTER TABLE 基本上等于"自杀式操作"😅。因为 MySQL 的 DDL 会申请元数据锁(MDL) ,写锁会直接阻塞所有读写请求,数据库连接池瞬间就能被打满,造成服务雪崩。而且在 MySQL 5.6 之前,很多 ALTER 操作都需要拷贝全表 ,千万行数据可能跑几十分钟甚至几个小时,业务完全停摆。就算用上 ALGORITHM=INPLACE,也还是有短暂 MDL 锁,在主从架构下还会造成严重延迟,风险极高。所以肯定不能直接搞。

面试官:

不错,痛点抓得很准。那你告诉我,正确、安全的姿势应该是什么?

候选人:

我们通常会使用无锁的第三方工具来做在线表结构变更 。最主流的就两个:pt-online-schema-change(Percona Toolkit 里的,简称 pt-osc)和 GitHub 开源的 gh-ost。它们都能做到在业务几乎无感知的情况下完成字段新增。✅

面试官:

嗯,看来你经验挺丰富。那先说说 pt-osc 具体是怎么工作的?

候选人:

好的。它主要分几步:

  1. 创建一张与原表结构一样的影子表(ghost table) ,并在影子表上执行我们想要的 ADD COLUMN
  2. 在原表上创建三个触发器(INSERT、UPDATE、DELETE),把增量变更实时同步到影子表。
  3. 分批(chunk)读取原表全量数据,拷到影子表,每次只拷几千行并短暂持有锁,避免长时间阻塞。
  4. 全量拷贝完成后,拿一个写锁,快速原子性 RENAME TABLE,把原表替换成影子表。
  5. 最后清理触发器和旧表。

整个过程对线上读写的阻塞基本可以忽略不计。⚠️ 但要注意,外键和触发器本身会带来额外写开销,如果云数据库限制触发器,这方案就不好使。

面试官:

逻辑清晰。那 gh-ost 呢?它和 pt-osc 最核心的区别在哪?

候选人:

最大的区别是 gh-ost 完全不用触发器 。它伪装成一个 MySQL 从库 ,去主实例上拉取 binlog,解析出增量变更,再应用到影子表上。全量拷贝的同时不断应用 binlog 事件,保证数据最终一致,最后也是原子 RENAME 切换。这样做的好处是:没有触发器的性能开销,对原表入侵极小;可以随时暂停、动态限速,还能在正式切换前设置一段观察期,非常安全,现在云上和开源界更推荐它。👍

面试官:

很好。那在真实场景下,你怎么决定用哪个工具?能给我画个简单的决策路径吗?

候选人:

当然可以,我画个决策树您看看:

基本上,如果环境允许,我会默认优先考虑 gh-ost,更干净利落。

面试官:

工具都会用,但魔鬼都在细节里。实际操作中,你会重点盯住哪些风险点?

候选人:

这正是老手和新手的区别。我一定提前做好这几项检查 🧐:

  • 磁盘空间 :影子表会复制全量数据,至少需要原表 1 倍以上额外空间,先 df -h 确保不会撑爆。
  • 主从延迟 :一定要给工具加限速参数,比如 gh-ost--max-lag-millis,防止从库跟不上。
  • 长事务 :密切关注是否有未提交的长事务,它们可能阻塞最后的 RENAME 切换。
  • 灰度回滚 :计划里要有回退预案,切换后若业务报错,能立刻 RENAME 回去。
  • 时间窗口:即便无锁,也尽量放凌晨低峰期,并留足监控观察时间。

面试官:

非常严谨。那要是业务场景特别复杂,比如不允许用这些外部工具,或者表有复杂外键关系,你还有别的路吗?

候选人:

有的,可以用新建附表 + 业务双写的迂回战术:

  1. 新建一张带有新字段的表。
  2. 业务代码先改造,对两张表进行双写(暂时以原表读为准)。
  3. 后台把原表存量数据慢慢灌入新表。
  4. 数据核对一致后,再通过配置或代码切换读流量到新表,然后下掉旧表。

这种方式对 DBA 和工具零依赖,但开发成本高,适合能接受短暂停服或灰度切换的业务。另外,如果是设计新系统,我会建议业务侧用 JSON 字段 预留扩展,或者做垂直拆分,从根本上减少大表加字段的频率。这算是架构层面的一点前瞻性思考 🚀。

面试官:

你刚才把工具原理讲得很透。那我再深问一步,能不能把pt-oscgh-ost核心执行命令写一下,顺便说说里面最有技术含量的参数?另外,这个场景你碰到过哪些实实在在的"坑",又是怎么解决的?

候选人:

没问题,这些都是压箱底的实操经验了。我直接上命令和坑点。

🔧 核心执行代码(有技术亮点)

1. pt-online-schema-change 生产级命令

shell 复制代码
pt-online-schema-change \
  --host=192.168.1.100 \
  --port=3306 \
  --user=root \
  --password=yourpass \
  --alter "ADD COLUMN new_field VARCHAR(64) NOT NULL DEFAULT ''" \
  D=your_db,t=user \
  --charset=utf8mb4 \
  --max-load="Threads_running=50" \
  --critical-load="Threads_running=100" \
  --chunk-size=2000 \
  --chunk-time=0.5 \
  --progress=time,30 \
  --statistics \
  --no-drop-old-table \
  --execute

技术亮点解析 💡:

  • --max-load="Threads_running=50"动态限速 神器。只要 MySQL 当前运行的线程数超过 50,工具就自动暂停拷贝,等负载降下来再继续。这是防止生产事故的关键。
  • --critical-load="Threads_running=100":负载超过这个阈值直接中止执行,宁可失败也不把数据库打死。
  • --chunk-size=2000--chunk-time=0.5:控制每块大小和执行时间,避免一次持有锁太久造成主从延迟。
  • --no-drop-old-table保命参数,保留旧表不删除,万一需要回滚还有余地。

2. gh-ost 生产级命令

shell 复制代码
gh-ost \
  --host=192.168.1.100 \
  --port=3306 \
  --user="ghost" \
  --password="ghost_pass" \
  --database="your_db" \
  --table="user" \
  --alter="ADD COLUMN new_field VARCHAR(64) NOT NULL DEFAULT ''" \
  --allow-on-master \
  --max-lag-millis=1500 \
  --throttle-control-replicas="192.168.1.101:3306;192.168.1.102:3306" \
  --chunk-size=2000 \
  --initially-drop-ghost-table \
  --initially-drop-old-table \
  --ok-to-drop-table \
  --execute

技术亮点解析 💡:

  • --allow-on-master:允许直接在 Master 上执行,不用费事切到从库。
  • --max-lag-millis=1500主从延迟超过 1.5 秒立刻暂停迁移 ,等从库消化完再继续。比 pt-osc 的 --max-lag 更精细。
  • --throttle-control-replicas:可以指定具体检查哪些从库的延迟情况,多从库环境下的精准控速
  • --initially-drop-ghost-table / --initially-drop-old-table:运行前清理上次失败的残留表,保证环境干净。
  • gh-ost 还有一个隐藏大招:可以通过 --postpone-cut-over-flag-file 设置一个文件,只有你手动删除该文件后才执行最终切换,给 DBA 留出人工确认的时间窗口,这个设计简直是生产保命符 😇。

3. 业务双写代码思路(无工具兜底方案)

如果连工具都不让用,手写双写也算一门绝活。

java 复制代码
// 新表 user_new 已经包含新字段
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;       // 原表
    @Autowired
    private UserNewMapper userNewMapper; // 新表

    @Transactional
    public void updateUser(User user) {
        // 双写:先更新原表,再更新新表
        userMapper.updateById(user);
        userNewMapper.updateById(user); 
        // 注意:可以利用 MQ 或 异步写新表,但要保证最终一致性
    }

    public User getUser(Long userId) {
        // 读:灰度期间按配置切换读来源
        if (ConfigService.readFromNewTable(userId)) {
            return userNewMapper.selectById(userId);
        } else {
            return userMapper.selectById(userId);
        }
    }
}

技术亮点 💡:

  • 通过配置中心灰度开关控制读流量,实现逐步放量,出问题秒级回滚切回原表。
  • 双写可以考虑异步消息队列解耦,但要承受短暂不一致,需业务允许。

🔥 此场景技术难点 & 解决方案一览

我画一张表,把常见大坑和保命方案整理清楚,面试官一看就知道你身经百战。

技术难点 现象与风险 ✅ 解决方案(落地细节)
MDL 锁阻塞 ALTER 或最后 RENAME 时,等待 MDL 写锁,所有读写堵死 1. 使用 gh-ost/pt-osc 减少锁时间 2. 切换前检查 innodb_lock_waits,kill 掉阻塞的长事务 3. 设置 lock_wait_timeout 避免无限等待
主从延迟雪崩 主库跑得快,从库重放 binlog 跟不上,导致从库数据滞后,主备切换丢数据 1. 设置 --max-lag-millis(gh-ost)或 --max-lag(pt-osc)动态暂停 2. 限制 --chunk-size--chunk-time 3. 强制迁移期间从库只读不承担部分读流量
磁盘空间爆满 影子表与全量 binlog 把磁盘占满,数据库直接挂掉 1. 事前SELECT DATA_LENGTH/1024/1024 FROM information_schema.tables 估算空间,留 1.5 倍冗余 2. 迁移前清理过期 binlog (PURGE BINARY LOGS) 3. 工具中加上 --assume-rbr 减少额外日志
大事务 / 长事务阻挡 存在未提交的读/写事务,最后 RENAME 切换时无法获取 MDL 锁,积压大量连接 1. 切换前查询 SHOW PROCESSLISTinformation_schema.innodb_trx,杀掉或等待 2. gh-ost 的 --postpone-cut-over-flag-file 机制,人工确认无长事务再切换
切换后业务报错 新字段 NOT NULL 无默认值、旧代码未兼容等,导致业务异常 1. 新字段一律加 DEFAULT 值,允许 NULL 可大幅降低风险 2. 业务代码做好兼容,DTO 增加字段前先放量观察 3. gh-ost 支持 --test-on-replica 先在从库测试
触发器性能开销 (pt-osc) 原表写操作倍增,CPU 飙升 1. 迁移前评估原表写入 QPS,过高(>1000/s)果断改用 gh-ost 2. 如果只能用 pt-osc,增加 --sleep 参数主动降速
工具自身崩溃残留 执行一半进程被杀,影子表和触发器残留,阻碍下次执行 1. 使用 --initially-drop-ghost-table 自动清理 2. 编写监控脚本,定期检查残留触发器并告警
云环境限制 RDS 可能禁用 SUPER 权限,无法创建触发器或设置 binlog_format 1. 优先选择 gh-ost(仅需 REPLICATION SLAVE, REPLICATION CLIENT 权限) 2. 确认 RDS 的 binlog 格式为 ROW,且保留时间足够

面试官:

你这个点讲得非常透彻,不仅给了命令,还把参数背后的"为什么"和线上血泪史都讲出来了。特别是那个 --postpone-cut-over-flag-file 的人工确认机制,很少人能提到。这个场景的技术难点你基本全盘掌握了,非常扎实。👍👍

候选人:

哈哈,都是踩过的坑教会我的,每个参数背后可能都是一次凌晨的告警😅。能把这些经验派上用场,也算没白熬。

【别走,交个朋友】

我是 Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。

如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain的Java大神之路」坐坐。

在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。

🔥技术这条路很酷,我们结伴同行。

🚀Keep Coding, Keep Loving ------ 与所有 Java 同路人共勉。

相关推荐
Rain的Java大神实战圈3 天前
日增量500万的表结构如何设计
场景设计题
Rain的Java大神实战圈5 天前
数据库主从有延迟怎么处理
架构设计·场景设计题
Rain的Java大神实战圈9 天前
分库分表会带来哪些问题如何解决
架构设计·场景设计题
Rain的Java大神实战圈10 天前
介绍一下分库分表
架构设计·场景设计题
Rain的Java大神实战圈11 天前
线上慢SQL的排查思路
经验分享·架构设计·场景设计题
Rain的Java大神实战圈12 天前
MySQL单表有10亿数据如何做迁移
架构设计·面试经验·场景设计题
Rain的Java大神实战圈15 天前
如何统计亿级用户的在线状态
架构设计·面试经验·场景设计题
Rain的Java大神实战圈16 天前
扫码登录的实现原理
架构设计·面试经验·场景设计题
Rain的Java大神实战圈18 天前
每日排行榜功能如何设计
redis·架构设计·面试经验·场景设计题