数据库迁移实践笔记
去年单机 MySQL 磁盘用到 85%,备份窗口也越拉越长。业务不能停,只能边跑边迁:先上主从,再分库分表。双写、对账、切流量这几步,哪一步算错了都得能回滚。
为什么需要迁移
性能问题
单机数据库扛不住了,需要升级架构。
扩展性问题
数据量太大,需要分库分表。
技术栈问题
从 MySQL 迁移到 PostgreSQL,或者从关系型数据库到 NoSQL。
停机迁移
简单场景
对于小数据量、容忍短时间停机的场景。
# 1. 停止应用
systemctl stop myapp
# 2. 导出数据
mysqldump -u root -p mydb > backup.sql
# 3. 导入到新库
mysql -u root -p newdb < backup.sql
# 4. 修改应用配置,连接新库
# 5. 启动应用
systemctl start myapp
# 6. 验证数据
优势:简单,不会出现数据不一致 劣势:需要停机,业务受影响
零停机迁移
双写方案
应用同时写新旧两个数据库。
class DualWriteDatabase:
def __init__(self, old_db, new_db):
self.old_db = old_db
self.new_db = new_db
def insert(self, table, data):
# 并发写入两个库
with ThreadPoolExecutor(max_workers=2) as executor:
executor.submit(self.old_db.insert, table, data)
executor.submit(self.new_db.insert, table, data)
def update(self, table, id, data):
with ThreadPoolExecutor(max_workers=2) as executor:
executor.submit(self.old_db.update, table, id, data)
executor.submit(self.new_db.update, table, id, data)
def delete(self, table, id):
with ThreadPoolExecutor(max_workers=2) as executor:
executor.submit(self.old_db.delete, table, id)
executor.submit(self.new_db.delete, table, id)
def get(self, table, id):
# 读优先新库
data = self.new_db.get(table, id)
if data:
return data
return self.old_db.get(table, id)
流程:
- 部署双写版本
- 验证新旧库数据一致
- 切换读流量到新库
- 验证业务正常
- 下线旧库
数据同步方案
使用工具同步数据。
使用 gh-ost:
# 安装 gh-ost
go install github.com/github/gh-ost@latest
# 在线迁移表
gh-ost \
--max-load=Threads_running=25 \
--critical-load=Threads_running=1000 \
--chunk-size=1000 \
--throttle-control-replicas="..." \
--database=mydb \
--table=users \
--alter="ENGINE=InnoDB,ADD COLUMN age INT" \
--allow-on-master \
--execute
使用 pt-online-schema-change:
# 安装 Percona Toolkit
apt-get install percona-toolkit
# 在线迁移表
pt-online-schema-change \
--alter="ENGINE=InnoDB,ADD COLUMN age INT" \
--critical-load=Threads_running=100 \
--max-load=Threads_running=25 \
--execute \
D=mydb,t=users
分库分表迁移
垂直分库
按业务拆分数据库。
单库: mydb
├── users
├── orders
├── payments
└── logs
垂直分库:
├── user_db (users)
├── order_db (orders, payments)
└── log_db (logs)
# 路由逻辑
class DatabaseRouter:
def __init__(self):
self.user_db = connect('user_db')
self.order_db = connect('order_db')
self.log_db = connect('log_db')
def get_connection(self, table):
if table.startswith('user'):
return self.user_db
elif table.startswith('order') or table.startswith('payment'):
return self.order_db
elif table.startswith('log'):
return self.log_db
raise ValueError(f"Unknown table: {table}")
水平分表
按数据量拆分表。
单表: users (1000 万行)
水平分表:
├── users_0
├── users_1
├── users_2
└── users_3
class ShardingRouter:
def __init__(self, num_shards=4):
self.shards = [connect(f'users_{i}') for i in range(num_shards)]
def get_shard(self, user_id):
# 根据 user_id 分片
return user_id % len(self.shards)
def get_user(self, user_id):
shard = self.get_shard(user_id)
return self.shards[shard].get_user(user_id)
def insert_user(self, user):
user_id = user['id']
shard = self.get_shard(user_id)
self.shards[shard].insert_user(user)
数据一致性
数据校验
迁移完成后,需要校验数据一致性。
def compare_tables(old_table, new_table):
old_data = old_table.all()
new_data = new_table.all()
if len(old_data) != len(new_data):
print(f"Row count mismatch: {len(old_data)} vs {len(new_data)}")
return False
for old_row, new_row in zip(old_data, new_data):
if old_row != new_row:
print(f"Data mismatch: {old_row} vs {new_row}")
return False
return True
# 定期校验
schedule.every().day.at("02:00").do(lambda: compare_tables(old_table, new_table))
数据修复
发现不一致后,需要修复数据。
def repair_data(old_table, new_table):
old_data = old_table.all()
for old_row in old_data:
new_row = new_table.get(old_row['id'])
if not new_row or new_row != old_row:
# 插入或更新
new_table.upsert(old_row)
print(f"Repaired: {old_row['id']}")
踩过的坑
坑一:数据结构不兼容
新旧数据库的字段类型不一样。
解决:迁移前做好字段映射,统一数据类型。
def transform_data(old_data):
return {
'id': old_data['id'],
'name': old_data['name'],
'age': int(old_data['age']), # 类型转换
'created_at': parse_date(old_data['created_at']), # 格式转换
}
坑二:外键约束
迁移时外键约束导致失败。
解决:暂时关闭外键约束,迁移后再打开。
-- 关闭外键约束
SET FOREIGN_KEY_CHECKS = 0;
-- 执行迁移
...
-- 打开外键约束
SET FOREIGN_KEY_CHECKS = 1;
坑三:索引不同步
新库的索引没建好,查询很慢。
解决:迁移前规划好索引,迁移后验证。
-- 创建索引
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);
-- 验证索引
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
坑四:双写失败
双写时,一个库成功了,另一个失败了。
解决:记录失败的操作,定期重试。
class DualWriteDatabase:
def __init__(self, old_db, new_db):
self.old_db = old_db
self.new_db = new_db
self.failed_writes = []
def insert(self, table, data):
old_success = self.safe_insert(self.old_db, table, data)
new_success = self.safe_insert(self.new_db, table, data)
if not (old_success and new_success):
# 记录失败的操作
self.failed_writes.append({
'table': table,
'data': data,
'timestamp': datetime.now()
})
def retry_failed(self):
for operation in self.failed_writes:
try:
self.insert(operation['table'], operation['data'])
self.failed_writes.remove(operation)
except Exception as e:
print(f"Retry failed: {e}")
迁移检查清单
迁移前
- 评估数据量和迁移时间
- 备份原数据库
- 设计迁移方案
- 准备回滚方案
- 通知相关方
迁移中
- 监控数据库性能
- 监控应用错误率
- 验证数据一致性
- 准备随时回滚
迁移后
- 验证业务功能
- 监控性能指标
- 清理临时数据
- 更新文档
- 总结经验
写在最后
数据库迁移这东西,不是技术问题,是工程问题。
准备好了:
- 备份方案
- 回滚方案
- 监控方案
- 验证方案
失败了,可能的原因:
- 测试不充分
- 监控不到位
- 回滚不及时
- 沟通不充分
迁移之前先问自己几个问题:
- 数据量多大?
- 业务能容忍多长时间停机?
- 有没有回滚方案?
- 团队有没有经验?
不是所有迁移都需要零停机,有时候简单粗暴的停机迁移反而更可靠。
这次数据库迁移花了一个月,从单机到主从,再到分库分表。迁移完成后,数据库 QPS 从 2000 提升到 10000,响应时间从 500ms 降到 50ms。虽然中间遇到过数据不一致、索引丢失等问题,但最终都解决了。
版权声明: 本文首发于 指尖魔法屋-数据库迁移实践笔记(https://blog.thinkmoon.cn/post/64-database-migration-zero-downtime-consistency/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。