数据库迁移:单机不够用了之后
数据库迁移:单机不够用了之后一旦进项目,好看的架构图就没那么管用了。
项目一开始很简单,一个 MySQL 单实例,几百万数据,响应时间也就几十毫秒。
问题是怎么来的
项目一开始很简单,一个 MySQL 单实例,几百万数据,响应时间也就几十毫秒。业务一路跑得顺畅,谁也没往那边想。直到某天线上监控开始告警,数据库 CPU 长期在 80% 以上波动,主从延迟从几百毫秒变成了几秒,有时候甚至分钟级。
这时候团队才意识到:原来的方案已经撑不住了。单机再怎么调优,硬件再怎么升级,总有个上限。物理存储也有极限,一张订单表跑到 8000 万行,全量扫一遍要十几秒,索引文件占了 200G。
当时我接手这个任务,第一反应是先摸底:监控、慢查询、表结构、业务逻辑全部过一遍。得先搞清楚哪块先扛不住,再谈迁不迁。
先搭主从,再做打算
在真正动手分库分表之前,先把主从搭起来。读流量暂时还走主库,这一步主要是给后续迁移留操作窗口。
我们用的是 MySQL 8.0,主从配置不算复杂,但有几个细节坑不少:
# my.cnf 主库配置
[mysqld]
server-id = 1
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
gtid_mode = ON
enforce_gtid_consistency = ON
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1
# my.cnf 从库配置
[mysqld]
server-id = 2
relay_log = relay-bin
read_only = ON
super_read_only = ON
relay_log_purge = OFF
server-id 必须全局唯一,这个是基础。gtid_mode 开启之后,故障切换会好办很多,不用再计算 binlog 位置。sync_binlog 和 innodb_flush_log_at_trx_commit 都是 1 的时候,性能会差一截,但数据丢失的风险降到最低——在迁移阶段,宁可慢一点,也不能让同步链路断掉。
主从启动后,先跑一段时间双写验证,读流量暂时不切。我们在代码里埋了个钩子,每次写主库时异步把同样请求发一份到从库,再对比结果。
这个过程持续了两周,发现了几处业务逻辑上的不一致:比如某些触发器在主从环境下的行为不一样,有个存储过程依赖了非确定性的函数。这些问题不在迁移阶段暴露,切线上的时候就要出大事故。
分库分表的那些坑
主从只是第一步,真正的硬骨头是分库分表。
当时订单表 8000 万行,每天新增 10 万左右。按照用户 ID 哈希分片,切成 4 个库、每个库 8 张表,一共 32 张表。路由规则是:db_index = user_id % 4,table_index = user_id / 4 % 8。
这个策略看着简单,但实施的时候遇到不少实际问题。
第一个问题是历史数据迁移。不能一次性把 8000 万行全搬过去,否则业务停机时间太长。我们采用的是"双写 + 数据校验 + 逐步切换"的方案:
import pymysql
import hashlib
import time
def get_db_index(user_id):
return user_id % 4
def get_table_index(user_id):
return (user_id // 4) % 8
def migrate_batch(start_id, batch_size, source_conn, target_conns):
"""
批量迁移数据,按用户ID范围分批
"""
cursor = source_conn.cursor()
query = """
SELECT * FROM orders
WHERE user_id >= %s AND user_id < %s
ORDER BY user_id
"""
cursor.execute(query, (start_id, start_id + batch_size))
rows = cursor.fetchall()
for row in rows:
user_id = row['user_id']
db_idx = get_db_index(user_id)
table_idx = get_table_index(user_id)
target_cursor = target_conns[db_idx].cursor()
# 插入目标表
insert_query = """
INSERT INTO orders_%s (user_id, order_no, amount, created_at)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
created_at = VALUES(created_at)
""" % table_idx
target_cursor.execute(insert_query, (
user_id,
row['order_no'],
row['amount'],
row['created_at']
))
target_conns[db_idx].commit()
target_cursor.close()
cursor.close()
return len(rows)
def verify_consistency(source_conn, target_conns, user_id):
"""
校验数据一致性
"""
source_cursor = source_conn.cursor()
source_cursor.execute(
"SELECT * FROM orders WHERE user_id = %s", (user_id,)
)
source_row = source_cursor.fetchone()
db_idx = get_db_index(user_id)
table_idx = get_table_index(user_id)
target_cursor = target_conns[db_idx].cursor()
target_cursor.execute(
"SELECT * FROM orders_%s WHERE user_id = %s" % (table_idx, user_id),
(user_id,)
)
target_row = target_cursor.fetchone()
# 比较关键字段
fields_to_check = ['order_no', 'amount', 'created_at']
for field in fields_to_check:
if source_row[field] != target_row[field]:
print(f"Inconsistency detected for user_id {user_id}, field {field}")
print(f"Source: {source_row[field]}, Target: {target_row[field]}")
return False
source_cursor.close()
target_cursor.close()
return True
这个脚本跑了整整 5 天,每批 5000 条,中间因为主键冲突、字符集不匹配、时间戳精度问题卡了几次。最麻烦的是有两个外键约束在目标环境里建不起来,最后只能先去掉约束,迁移完之后再补回来。
第二个问题是路由改造成本。原有代码里,大部分 SQL 都没有带上 user_id 作为条件,导致没法直接路由。我们做了一个轻量级的 SQL 解析器,在执行之前先检测语句能不能推导出分片键。如果不能,就通过一个中间表查一下用户对应的分片信息。
这个方案虽然能跑,但性能损耗不小,平均每次查询多了 5-10ms。后来我们把用户信息做了本地缓存,才勉强降下来。
数据一致性怎么保
分布式环境下,最头疼的就是跨分片事务。
一个订单可能关联多个商品,而这些商品又属于不同的分片。如果订单在分片 A,商品在分片 B,那么下单的时候要么做两阶段提交,要么改业务逻辑接受最终一致性。
我们当时没有上分布式事务框架(比如 Seata),而是选择了业务层面的补偿机制:
- 先在订单所在分片创建订单记录,状态设为"处理中"
- 异步扣减库存,如果失败就更新订单状态为"失败"
- 如果成功,更新订单状态为"完成"
- 有个定时任务定期检查"处理中"超过一定时间的订单,做补偿处理
def create_order_with_compensation(user_id, items):
"""
创建订单,带补偿机制
"""
db_idx = get_db_index(user_id)
table_idx = get_table_index(user_id)
order_no = generate_order_no()
total_amount = sum(item['price'] * item['quantity'] for item in items)
# 第一步:创建订单记录
cursor = db_conns[db_idx].cursor()
try:
cursor.execute("""
INSERT INTO orders_%s
(user_id, order_no, total_amount, status, created_at)
VALUES (%s, %s, %s, 'PROCESSING', NOW())
""" % table_idx, (user_id, order_no, total_amount))
order_id = cursor.lastrowid
db_conns[db_idx].commit()
except Exception as e:
db_conns[db_idx].rollback()
print(f"创建订单失败: {e}")
return None
# 第二步:异步扣减库存
try:
inventory_result = deduct_inventory(items)
if not inventory_result['success']:
# 扣减失败,更新订单状态
cursor.execute("""
UPDATE orders_%s
SET status = 'FAILED', fail_reason = %s
WHERE id = %s
""" % table_idx, (inventory_result['message'], order_id))
db_conns[db_idx].commit()
return None
# 第三步:更新订单状态为完成
cursor.execute("""
UPDATE orders_%s
SET status = 'COMPLETED'
WHERE id = %s
""" % table_idx, (order_id,))
db_conns[db_idx].commit()
return order_id
except Exception as e:
# 异常情况,记录下来等待补偿
print(f"扣减库存异常: {e}")
# 订单保持 PROCESSING 状态,由补偿任务处理
return order_id
def compensation_task():
"""
定时补偿任务
"""
for db_idx in range(4):
cursor = db_conns[db_idx].cursor()
# 查找处理中超过 5 分钟的订单
cursor.execute("""
SELECT id, order_no, user_id
FROM orders
WHERE status = 'PROCESSING'
AND created_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE)
""")
stale_orders = cursor.fetchall()
for order in stale_orders:
# 尝试重新处理或取消订单
order_id = order['id']
try:
result = compensate_order(order_id)
if result['success']:
cursor.execute("""
UPDATE orders
SET status = %s, fail_reason = %s
WHERE id = %s
""" % (result['status'], result.get('message', ''), order_id))
else:
cursor.execute("""
UPDATE orders
SET status = 'FAILED', fail_reason = %s
WHERE id = %s
""" % (result['message'], order_id))
db_conns[db_idx].commit()
except Exception as e:
print(f"补偿订单 {order_id} 失败: {e}")
db_conns[db_idx].rollback()
cursor.close()
这个方案谈不上优雅,但在有限的时间和人力约束下,是一个可接受的折中。后续订单量继续增长的时候,我们才慢慢把核心业务迁移到了基于 Saga 模式的框架上。
回滚策略怎么想
迁移最怕的,往往是迁完出问题却回不去。
我们在做迁移计划的时候,专门留了"快速回滚"的预案。核心思路是:新旧系统并行运行,通过灰度开关控制流量,任何时候都能一键切回旧系统。
class MigrationRouter:
def __init__(self):
self.new_system_enabled = False
self.gray_user_ids = set()
self.gray_ratio = 0.0
def should_use_new_system(self, user_id):
"""
判断是否应该使用新系统
"""
if not self.new_system_enabled:
return False
if user_id in self.gray_user_ids:
return True
# 基于哈希的灰度
user_hash = int(hashlib.md5(str(user_id).encode()).hexdigest()[:8], 16)
return (user_hash % 100) < (self.gray_ratio * 100)
def enable_new_system(self, gray_ratio=0.0):
"""
启用新系统,并设置灰度比例
"""
self.new_system_enabled = True
self.gray_ratio = gray_ratio
def disable_new_system(self):
"""
禁用新系统(回滚)
"""
self.new_system_enabled = False
self.gray_ratio = 0.0
def add_gray_user(self, user_id):
"""
添加灰度用户
"""
self.gray_user_ids.add(user_id)
# 使用示例
router = MigrationRouter()
# 先灰度 1% 的流量
router.enable_new_system(gray_ratio=0.01)
# 监控一段时间后,逐步放开
router.enable_new_system(gray_ratio=0.1)
router.enable_new_system(gray_ratio=0.5)
# 如果出现问题,立即回滚
router.disable_new_system()
灰度期间,我们主要监控这几个指标:P99 响应时间、错误率、主从延迟、数据不一致次数。任何一个指标超过阈值,就暂停扩灰,先定位问题。
有一次我们在扩到 30% 的时候,发现某个分片的 CPU 突然飙升。查了一圈,原来是一个新上线的报表功能没有适配分片逻辑,把全量查询都打到了同一个分片上。这个不应该是迁移的问题,但它确实暴露了旧系统就存在的隐患。我们连夜修了这个报表,才继续推进。
事后复盘
从单机迁到分布式,动的是整套架构,不是换个数据库版本那么简单。几条我后来才想明白的:
别指望一次迁完。 分阶段、小步快跑,每一步都留回滚预案,风险才可控。
一致性没有银弹。 要么接受最终一致,要么上分布式事务框架——后者性能和复杂度都会上去。我们初期用业务补偿,量上来后再慢慢升级。
监控得提前就位。 分片不均、热点、主从延迟这些问题,靠人眼看不出,得靠指标。没监控就是盲人摸象。
团队要时间适应。 新架构意味着新的排查方式和开发规范,一天学不会。
数据库迁移很多时候是必答题,没有完美方案,只有适应当前业务和人力约束的妥协。路走完之后回头看,那些坑和弯路也算系统演进的一部分。
当然,下次有人问你「要不要分库分表」,先反问一句:确定当前的问题真的是分库分表能解决的吗?有时候,加个索引、优化一条 SQL、调整一下硬件配置,效果可能更直接。
可用性说明:本文发布于 2021 年 6 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-数据库迁移:单机不够用了之后(https://blog.thinkmoon.cn/post/133-migration-single-to-distributed-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。