article.read --id=195

重构的勇气:在不改变行为的前提下改善结构

// published: 2025-08-26

数据库变更,这个看似简单的操作,却是生产环境中最危险的操作之一。一个不慎的ALTER TABLE可能导致数据丢失,一个缺少索引的查询可能拖垮整个系统,一个锁表的操作可能让服务停摆数小时。在数据库的世界里,谨慎不是胆怯,而是智慧。数据库是应用的心脏,数据库变更是心脏手术,需要精心规划、谨慎执行、密切监控。

让我们理解数据库变更的复杂性。在开发环境中,数据库变更很简单:删除数据库,重新创建,导入测试数据,一切从头开始。但在生产环境中,一切都不同了:数据库中有真实的用户数据,不能删除;服务必须保持运行,不能停机;变更必须可回滚,以防出现问题;性能不能下降,用户体验不能受影响。这些约束让数据库变更变得复杂而危险。添加列看似简单,但如果表很大(数百万或数十亿行),ALTER TABLE可能需要锁表数小时,在此期间所有写操作都会被阻塞,服务实际上处于不可用状态。删除列更危险,如果有代码还在使用这个列,会导致运行时错误,而且这种错误可能不会立即暴露,而是在某个特定的代码路径被触发时才出现。修改列类型可能导致数据丢失,比如把VARCHAR改成INT,非数字的数据会丢失或变成NULL。添加索引可能导致锁表,影响正在运行的查询,而且索引的创建时间取决于表的大小,可能需要几分钟到几小时。更复杂的是,数据库变更往往需要和代码变更配合:先部署代码还是先变更数据库?如果顺序错了,可能导致服务不可用。

案例分析:GitHub在数据库变更上的实践展示了如何在大规模系统中安全地演进数据库。GitHub的数据库有数十亿行数据,包含了全球数千万开发者的代码和协作信息,传统的ALTER TABLE会锁表数小时,这是不可接受的。GitHub开发了一套工具和流程来实现零停机的数据库变更。对于添加列,他们使用"影子表"技术(也叫pt-online-schema-change):创建一个新表,包含新列,然后逐步将数据从旧表复制到新表,同时用触发器保持两个表的同步,最后切换表名。这个过程可能需要数天,但不会影响服务,因为复制是在后台进行的,不会锁表。对于删除列,他们采用"三步走"策略:第一步,部署代码停止使用该列,但不删除列;第二步,观察一段时间(通常是几周)确保没有问题,监控日志确保没有代码还在访问该列;第三步,删除列。这个过程可能需要数周,但很安全,即使出现问题也有足够的时间回滚。对于修改列类型,他们会创建新列,双写数据(同时写入旧列和新列),逐步迁移读取逻辑到新列,最后删除旧列。这个过程更复杂,但能确保零停机。GitHub还建立了严格的数据库变更审查流程:所有变更必须经过DBA审查,DBA会检查变更的影响范围、锁表时间、回滚方案等;必须在测试环境验证,使用生产环境的数据量级进行测试,确保性能可接受;必须有回滚方案,每个变更都要有对应的回滚脚本,并且在执行前测试过;必须在低峰期执行,通常是凌晨或周末,此时用户量最少,即使出现问题影响也最小。他们还开发了自动化工具来检测危险的变更,比如缺少WHERE子句的UPDATE语句,可能导致全表更新;没有LIMIT的DELETE语句,可能删除大量数据;在大表上添加索引而没有使用CONCURRENTLY选项(PostgreSQL)等。

深度思考:数据库变更的核心原则是"向后兼容"。新版本的数据库schema应该能够支持旧版本的代码,这样就可以先变更数据库,再部署代码,或者反过来,顺序不会导致问题。如果做不到向后兼容,就需要将变更拆分成多个步骤,每个步骤都保持兼容性。比如要删除一个列,不能直接删除,而是先让代码停止使用,再删除列。另一个重要原则是"可观测性":变更后要密切监控数据库的性能指标,如查询延迟、锁等待、磁盘IO、连接数等,及时发现问题。可以使用数据库的慢查询日志、性能监控工具、APM系统等来监控。还有一个常被忽视的原则是"可回滚性":每个变更都应该有对应的回滚脚本,并且在执行前测试过。回滚脚本不是简单的反向操作,而是要考虑数据的一致性。比如添加了一个非空列,回滚时删除该列,但如果期间有新数据写入,这些数据的该列有值,删除列会丢失这些数据。数据库变更也需要版本管理,就像代码一样。使用Flyway、Liquibase等工具可以将数据库变更脚本纳入版本控制,自动化执行,记录变更历史。这样可以确保开发、测试、生产环境的数据库schema保持一致,也方便回滚和审计。

结语:数据库是应用的心脏,数据库变更是心脏手术。它需要精心规划、谨慎执行、密切监控。当我们以敬畏之心对待数据库变更,它就不会成为生产事故的导火索,而是系统演进的稳健步伐。数据库变更的艺术在于平衡:在保证安全的前提下,尽可能高效地演进数据库。

数据库变更还需要考虑数据迁移的问题。有时候变更不仅是修改schema,还需要迁移数据。比如拆分一个表成两个表,需要将数据从旧表迁移到新表。数据迁移要考虑数据量、迁移时间、数据一致性等问题。对于大表,可以分批迁移,每次迁移一部分数据,避免长时间锁表。可以使用后台任务逐步迁移,同时保持新旧数据的同步。数据库变更的测试也很重要,不能只在开发环境测试,还要在类生产环境测试,使用接近生产环境的数据量和负载,确保变更在生产环境也能正常工作。