1. 千万级数据查询的困境与破局
刚接手这个千万级用户数据的系统时,每次执行查询都像在等待一场审判。那个包含用户行为日志的主表现在有2300万条记录,简单的SELECT COUNT(*)都要花费12秒——这还只是开发环境的测试数据。生产环境的情况更糟,高峰期经常出现查询超时,用户投诉像雪片一样飞来。
这就是典型的单表瓶颈:当MySQL单表数据量突破千万级,即使你给字段加了索引,查询性能也会断崖式下跌。我见过太多团队在这个阶段病急乱投医——疯狂加索引、买更贵的服务器、甚至考虑上NewSQL数据库。但真实情况是,在数据量达到这个量级时,分库分表往往是性价比最高的解决方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分库分表核心策略解析
2.1 水平拆分 vs 垂直拆分
先明确一个概念:分库分表本质上是两种策略的组合拳:
- 分库:把数据库实例拆分成多个(如user_db_1, user_db_2)
- 分表:把表拆分成多个子表(如user_table_001, user_table_002)
具体到实现方式,又分为:
-
水平拆分(横向拆分)
- 按行拆分,每个子表结构完全相同
- 比如按user_id范围拆分:0-100万在table_1,100-200万在table_2
- 适合行数巨大的表(我们的用户日志表就是典型)
-
垂直拆分(纵向拆分)
- 按列拆分,把宽表拆成多个窄表
- 比如把用户基本信息和用户扩展信息分开存储
- 适合字段超多的大宽表(超过30个字段的表要考虑)
关键选择:我们的用户日志表字段不多(15个),但数据量暴涨,显然应该用水平拆分。而用户主表有38个字段,更适合垂直拆分后对核心表再做水平拆分。
2.2 分片键的选择艺术
选错分片键等于埋雷。去年有个惨痛案例:某电商按订单号hash分片,结果发现商家查询自己所有订单时要跨所有分片查询,性能直接崩盘。
经过多次踩坑,我总结出分片键选择的黄金法则:
-
高频查询条件必须包含分片键
- 比如用户中心99%的查询都带user_id,那它必须是分片键
- 否则会出现"全分片扫描"的灾难场景
-
避免热点问题
- 按时间分片会导致新数据都写入最后一个分片
- 解决方案:组合分片键(时间+user_id取模)
-
分片键不可变
- 一旦记录的分片位置确定就不能修改
- 所以千万别用可能会变的字段(如手机号、邮箱)
在我们的日志系统中,最终选择user_id的后两位 + 创建月份作为复合分片键。这样既保证用户维度的查询效率,又避免单月数据过热。
3. 实战分库分表示例
3.1 分片路由设计
这是最核心的代码片段(Java示例):
java复制public class TableRouter {
// 分库总数
private static final int DB_COUNT = 4;
// 每个库的分表数量
private static final int TABLE_COUNT_PER_DB = 8;
public static String route(long userId) {
// 1. 计算分库位置
int dbIndex = (int) (userId % DB_COUNT);
// 2. 计算分表位置
int tableIndex = (int) ((userId / DB_COUNT) % TABLE_COUNT_PER_DB);
return String.format("user_db_%d.user_tab_%02d", dbIndex, tableIndex);
}
}
这个算法保证:
- 同一个用户的数据始终落在同一个物理表
- 数据均匀分布在32个分片(4库×8表)
- 扩容时只需要调整DB_COUNT常量
3.2 分布式ID生成方案
分库分表后,自增ID就成了灾难。我们采用改良版雪花算法:
java复制public class SnowflakeIdGenerator {
private final long datacenterId; // 数据中心ID
private final long machineId; // 机器ID
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨异常");
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - 1288834974657L) << 22)
| (datacenterId << 17)
| (machineId << 12)
| sequence;
}
}
这个实现解决了三个关键问题:
- 每个实例的machineId必须唯一(通过ZK分配)
- 处理时钟回拨异常
- 每秒可生成409.6万个ID(足够大多数场景)
4. 查询中间件选型对比
4.1 ShardingSphere vs MyCat
| 特性 | ShardingSphere 5.x | MyCat 2.0 |
|---|---|---|
| 协议支持 | MySQL/PostgreSQL | 仅MySQL |
| 分片策略 | 10+种内置算法 | 5种 |
| 分布式事务 | XA/SAGA/SEATA | XA |
| 监控界面 | Prometheus集成 | 简陋 |
| 社区活跃度 | Apache顶级项目 | 一般 |
我们最终选择ShardingSphere的原因:
- 对SQL方言的支持更全面(遇到JSON函数也能解析)
- 弹性伸缩方案更成熟(正在测试它的影子库方案)
- 与SpringBoot生态无缝集成
4.2 分页查询的坑与解决方案
分库分表后,LIMIT 10000, 10这种分页会引发性能灾难。我们的解决方案:
-
业务妥协方案:
- 禁止跳页查询,只允许"下一页"
- 每次携带上一页最后记录的ID
sql复制SELECT * FROM user WHERE id > ? ORDER BY id LIMIT 10 -
内存归并方案:
java复制public Page<User> queryUsers(int pageSize, ShardingCondition condition) { // 并行查询所有分片 List<Future<List<User>>> futures = shards.stream() .map(shard -> executor.submit(() -> queryShard(shard, condition))) .collect(Collectors.toList()); // 归并排序 List<User> merged = futures.stream() .flatMap(f -> f.get().stream()) .sorted(comparing(User::getCreateTime)) .limit(pageSize) .collect(Collectors.toList()); return new Page(merged); } -
ES辅助方案:
- 将分片数据同步到Elasticsearch
- 复杂分页查询走ES
- 用scroll API处理深度分页
5. 数据迁移实战记录
5.1 双写迁移方案
这是我们在不停机情况下迁移2000万数据的步骤:
-
阶段一:双写准备
- 上线新代码,同时写入新旧库
- 旧库为主数据源,开启binlog监听
-
阶段二:全量迁移
bash复制# 用DataX执行批量迁移 python datax.py job/user_migration.jsonjob配置关键点:
json复制{ "speed": { "channel": 4, "byte": 1048576 }, "errorLimit": { "record": 1000 } } -
阶段三:增量追赶
- 启动Canal监听旧库变更
- 延迟控制在5分钟内
-
阶段四:流量切换
- 修改配置中心参数
- 新库作为主数据源
- 持续双写1周后下线旧库
5.2 数据一致性校验
我们开发了比对工具,核心逻辑:
python复制def verify_table(old_conn, new_conn, table):
# 抽样校验
sample_ids = old_conn.query(f"SELECT id FROM {table} SAMPLE 0.1")
for id in sample_ids:
old_row = old_conn.query(f"SELECT * FROM {table} WHERE id = {id}")
new_row = new_conn.query(f"SELECT * FROM {table} WHERE id = {id}")
if old_row != new_row:
record_mismatch(id, old_row, new_row)
# 计数校验
old_count = old_conn.query(f"SELECT COUNT(*) FROM {table}")
new_count = new_conn.query(f"SELECT COUNT(*) FROM {table}")
if old_count != new_count:
raise Exception(f"计数不一致: old={old_count}, new={new_count}")
校验过程中发现三个典型问题:
- 新库的datetime字段丢失了毫秒精度(调整字段定义解决)
- 自增ID冲突(改用雪花ID解决)
- 唯一索引重复(清洗脏数据后重试)
6. 踩坑经验大全
6.1 分布式事务的抉择
我们对比了三种方案:
-
XA协议:
- 优点:强一致性
- 缺点:性能差(TPM下降40%)
- 适用场景:资金交易等强一致性要求
-
SEATA AT模式:
- 优点:性能较好,侵入性低
- 缺点:需要额外部署TC服务
- 适用场景:大多数业务场景
-
最终一致性:
- 优点:性能最好
- 缺点:业务需要容忍延迟
- 适用场景:日志、统计类业务
最终方案:核心交易用XA,普通业务用SEATA,日志类用消息队列+重试。
6.2 热点问题应急方案
大促期间突然出现某个分片CPU100%,紧急处理步骤:
-
实时监控发现user_tab_15负载异常
sql复制-- 查看正在执行的查询 SHOW PROCESSLIST; -- 查看锁等待 SELECT * FROM performance_schema.events_waits_current; -
分析发现是某个网红主播的粉丝集中访问
java复制// 临时方案:本地缓存粉丝列表 @Cacheable(value = "fansList", key = "#anchorId") public List<Long> getFansList(long anchorId) { // ... } -
长期方案:对该主播数据单独分片
java复制// 特殊分片规则 if (userId == 特别用户ID) { return "user_db_special.user_tab_00"; }
7. 性能对比数据
迁移前后的关键指标对比:
| 指标 | 分表前 | 分表后(32分片) |
|---|---|---|
| 单条查询平均耗时 | 1200ms | 35ms |
| 批量插入QPS | 1500 | 9800 |
| 磁盘空间占用 | 1.2TB | 总800GB(压缩后) |
| 高峰期CPU使用率 | 95% | 45% |
| 备份耗时 | 6小时 | 30分钟 |
特别提醒:不是所有场景都适合分库分表。如果贵司数据量在500万以下,建议优先考虑:
- 优化索引(执行计划分析)
- 归档历史数据
- 升级SSD存储
- 使用读写分离
