第17章:数据库重构——当数据层也开始“还债”
说实话,很多团队对技术债务的理解,都停留在代码层面。
我见过太多项目,代码重构得漂漂亮亮,一上线就崩——因为数据库没动。表结构还是三年前的烂摊子,索引乱得像蜘蛛网,存储过程里藏着十几个没人敢改的bug。
数据库重构,说白了就是给数据层还债。但它比代码重构更棘手。为什么?因为数据是活的,是有状态的。你改代码,大不了重新部署;你改表结构,线上几千万条数据怎么办?
核心观点:数据库重构必须与代码重构协同进行。两者是“双轮驱动”,缺一个,车就翻。
17.1 数据库重构与代码重构的协同
我早期犯过一个错:代码层把用户模块拆成了微服务,数据库却还是单库单表。结果服务是拆了,但所有请求还是打到同一张表上,性能瓶颈一点没解决。
从那以后,我定了一条铁律:数据库重构和代码重构,必须同步规划,分步实施。
怎么协同?我总结了一个“三步走”策略:
- 先分析依赖——搞清楚代码里哪些SQL、哪些ORM映射、哪些存储过程,依赖了哪些表、哪些字段。这一步不做,后面全是盲人摸象。
- 再设计过渡方案——数据库重构不能“一刀切”。比如要拆表,先建新表,写双写逻辑,等数据一致了再切流量。这叫“渐进式重构”。
- 最后统一上线——代码和数据库的变更,必须打包成一个发布单元。我习惯用同一个发布窗口,同时部署应用变更和数据库迁移脚本。
我的小技巧:在代码里加一个“功能开关”。数据库迁移完成后,先打开开关让新逻辑跑一段时间,确认没问题再关掉旧逻辑。这样随时可以回滚。
17.2 数据库版本控制——Flyway vs Liquibase
代码有Git,数据库呢?
很多团队还在用手工执行SQL脚本的方式管理数据库变更。今天张三改个字段,明天李四加个索引,版本全靠口口相传。结果就是:测试环境和生产环境的表结构永远对不上。
数据库版本控制工具就是来解决这个问题的。目前主流的两款:Flyway 和 Liquibase。
| 对比维度 | Flyway | Liquibase |
|---|---|---|
| 配置方式 | 纯SQL脚本,命名约定(如 V1__init.sql) | XML / YAML / JSON / SQL 多种格式 |
| 学习曲线 | 低,会SQL就能用 | 中等,需要理解变更集(changeset)概念 |
| 回滚支持 | 需手动编写回滚脚本 | 内置回滚机制 |
| 适用场景 | 中小团队,快速上手 | 大型企业,复杂变更管理 |
我个人习惯用 Flyway。原因很简单:它够轻量,够直接。你只需要按约定命名脚本,它自动按版本号顺序执行。而且它会在数据库里建一张 flyway_schema_history 表,记录每次迁移的状态。
注意:Flyway 默认不允许修改已执行过的脚本。如果你改了 V2 版本的脚本,它会报错。这是好事——强制你走“新版本”的方式去修改,而不是偷偷改历史。
Liquibase 我也用过。它的优势在于变更集可以跨数据库平台,而且支持更细粒度的控制。比如你可以定义“这个变更只在开发环境执行,生产环境跳过”。但代价是配置更复杂,XML 写起来有点啰嗦。
17.3 表结构演进策略
数据库表结构不是一成不变的。业务在变,表就得跟着变。但怎么变,有讲究。
我总结了四种常见的演进策略:
- 新增字段——最安全的方式。只加不减,对现有代码无影响。但要注意:新增字段尽量设置默认值,否则历史数据会报错。
- 重命名字段——比较危险。我建议分三步走:先新增字段,再双写,最后删旧字段。别想着一步到位。
- 拆分表——比如把一张大用户表拆成 user_base 和 user_ext。需要写数据迁移脚本,还要改代码里的查询逻辑。
- 合并表——比如把订单表和订单详情表合并。这个更复杂,因为涉及数据去重和关联关系重建。
避坑指南:我曾经在线上直接执行了一条 ALTER TABLE 重命名列,结果导致所有 ORM 映射全部报错。因为 ORM 的实体类里字段名没同步改。从那以后,我坚持“数据库变更和代码变更必须在一个 PR 里”。
17.4 数据迁移脚本编写
数据迁移脚本,是数据库重构的核心产出。写不好,轻则数据丢失,重则生产事故。
我写迁移脚本的几个原则:
- 幂等性——同一个脚本执行两次,结果必须一样。用
IF NOT EXISTS、CREATE OR REPLACE这类语法。 - 可回滚——每个迁移脚本,必须配一个回滚脚本。Flyway 虽然不强制,但我自己会写。
- 分批处理——如果涉及大量数据更新,不要一次性 UPDATE 全表。用
LIMIT分批跑,避免锁表。
举个例子:假设我们要把 users 表中的 phone 字段拆成 country_code 和 phone_number 两个字段。
-- 迁移脚本 V2__split_phone_field.sql
-- 1. 新增字段
ALTER TABLE users ADD COLUMN country_code VARCHAR(5) DEFAULT '+86';
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);
-- 2. 数据迁移(分批处理,每次1000条)
DO $$
DECLARE
batch_size INT := 1000;
offset_val INT := 0;
affected_rows INT;
BEGIN
LOOP
UPDATE users
SET country_code = '+86',
phone_number = phone
WHERE phone IS NOT NULL
AND phone_number IS NULL
LIMIT batch_size;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
EXIT WHEN affected_rows = 0;
COMMIT;
offset_val := offset_val + batch_size;
END LOOP;
END $$;
-- 3. 添加非空约束(确认数据迁移完成后)
-- ALTER TABLE users ALTER COLUMN phone_number SET NOT NULL;
提示:最后一步加非空约束我注释掉了。为什么?因为线上可能有历史数据 phone 字段为空。我会先跑一遍数据校验脚本,确认没问题再手动执行约束添加。安全第一。
回滚脚本怎么写?
-- 回滚脚本 U2__rollback_split_phone_field.sql
-- 1. 恢复数据
UPDATE users SET phone = phone_number WHERE phone_number IS NOT NULL;
-- 2. 删除新增字段
ALTER TABLE users DROP COLUMN IF EXISTS phone_number;
ALTER TABLE users DROP COLUMN IF EXISTS country_code;
知识体系总览
下面这张图,是我对本章内容的结构化梳理。你可以把它当作数据库重构的“作战地图”。
数据库重构没有银弹。但只要你把协同、版本控制、演进策略、迁移脚本这四个维度都考虑到了,至少不会出大问题。
嗯,今天就先聊到这儿。记住一句话:改代码不改数据库,是半截子重构;改数据库不改代码,是给自己挖坑。