分库分表实战:ShardingSphere路由策略与跨分片查询的限制
2026/4/30大约 8 分钟
分库分表实战:ShardingSphere路由策略与跨分片查询的限制
适读人群:Java后端开发、架构师、对分库分表有实际需求的工程师 | 阅读时长:约26分钟
开篇故事
2020年,我们的订单表突破了5亿行。
单表5亿行是什么概念?一个简单的 WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20,就算有索引,也要扫描几十万行,响应时间超过500ms。
DBA发出最后通牒:「老张,这个月必须搞分库分表,否则双十一你们自己看着办。」
我们选择了ShardingSphere(当时是4.x版本),分了16个库,每个库16张表,共256张分片。
但上线后,新的问题接踵而来:
- 运营要查「过去30天下单量最多的用户Top10」→ 需要跨所有分片聚合,直接超时
- 财务要查「某个时间段内所有订单」→ 不带分片键,全路由到256张表,吃不消
- 后台要分页展示所有订单 → 跨分片分页乱序,数据不对
今天把分库分表的路由策略、限制和绕过方案完整讲一遍。
一、什么时候才需要分库分表
先说结论:分库分表是最后手段,代价极大。
单表优化路径(先走完这些):
→ 索引优化(EXPLAIN)
→ 查询优化(SQL重写)
→ 读写分离(主从复制)
→ 归档历史数据(冷热分离)
→ 升级硬件(SSD、更多内存)
→ 业务层缓存(Redis)
如果以上都做了,单表行数超过 5000万~1亿,查询仍然慢:
→ 才考虑分库分表二、底层原理:ShardingSphere的路由机制
2.1 分片键的重要性
ShardingSphere的路由逻辑:
SQL执行流程:
应用层SQL:
INSERT INTO orders(id, user_id, amount) VALUES(123, 456, 100)
↑
分片键(user_id)
ShardingSphere路由:
库分片算法: user_id % 16 = 456 % 16 = 8 → db_8
表分片算法: user_id % 16 = 456 % 16 = 8 → orders_8
改写后的SQL:
INSERT INTO db_8.orders_8(id, user_id, amount) VALUES(123, 456, 100)2.2 三种路由类型
精确路由(Sharding Key = 常量):
WHERE user_id = 456
→ 只路由到1个分片
→ 性能最好
范围路由(Sharding Key BETWEEN/>/< ):
WHERE user_id BETWEEN 100 AND 200
→ 路由到多个分片(取决于范围覆盖哪些分片)
→ 性能中等
全路由(无Sharding Key 或 不等式条件):
WHERE status = 'PAID' ← 没有分片键
→ 广播到所有256个分片
→ 性能最差,慎用2.3 全路由的危害
全路由执行过程:
1. 广播SQL到256个分片(16库 × 16表)
2. 每个分片各自执行SQL(256次DB查询)
3. 归并结果集(内存排序/聚合)
4. 返回最终结果
问题:
- 256次DB连接建立(每次约1ms = 256ms额外开销)
- 256个分片各自IO(磁盘放大256倍)
- 内存中归并排序(5亿行/256 ≈ 200万行 × 256个分片 = 5亿行归并)
- 响应时间:秒级甚至超时三、完整解决方案与代码
3.1 ShardingSphere-JDBC配置
# application.yml(ShardingSphere 5.x)
spring:
shardingsphere:
datasource:
names: db0,db1,db2,db3,db4,db5,db6,db7,db8,db9,db10,db11,db12,db13,db14,db15
db0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://mysql-node1:3306/orders_db0
username: root
password: password
hikari:
maximum-pool-size: 20
# ... db1~db15 类似配置
rules:
sharding:
tables:
orders:
actual-data-nodes: db$->{0..15}.orders_$->{0..15}
# 库分片策略:按user_id取模
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-inline
# 表分片策略:按user_id取模
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: table-inline
# 主键生成(雪花算法)
key-generate-strategy:
column: id
key-generator-name: snowflake
sharding-algorithms:
db-inline:
type: INLINE
props:
algorithm-expression: db$->{user_id % 16}
table-inline:
type: INLINE
props:
algorithm-expression: orders_$->{user_id % 16}
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
props:
sql-show: true # 开发环境打印路由后的SQL,便于调试3.2 正确使用分片键的查询
@Repository
public class OrderRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 正确:带分片键查询(精确路由到1个分片)
* 耗时:约5ms
*/
public List<Order> findByUserId(Long userId) {
// ShardingSphere自动路由:user_id % 16 → 某个db + 某张table
return jdbcTemplate.query(
"SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
ROW_MAPPER, userId
);
}
/**
* 正确:带分片键 + 其他条件(精确路由 + 过滤)
* 耗时:约8ms
*/
public List<Order> findByUserIdAndStatus(Long userId, String status) {
return jdbcTemplate.query(
"SELECT * FROM orders WHERE user_id = ? AND status = ? " +
"ORDER BY created_at DESC LIMIT 20",
ROW_MAPPER, userId, status
);
}
/**
* 谨慎:范围查询(可能路由到多个分片)
* 如果user_id范围覆盖多个分片,耗时会增加
*/
public List<Order> findByUserIdRange(Long fromUserId, Long toUserId) {
return jdbcTemplate.query(
"SELECT * FROM orders WHERE user_id BETWEEN ? AND ? LIMIT 1000",
ROW_MAPPER, fromUserId, toUserId
);
}
/**
* 危险:无分片键查询(全路由,256个分片都要查)
* 耗时:秒级,只适合低频的管理后台查询
*/
public List<Order> findByStatus(String status) {
// ShardingSphere会路由到所有256个分片
// 谨慎使用!
return jdbcTemplate.query(
"SELECT * FROM orders WHERE status = ? LIMIT 100",
ROW_MAPPER, status
);
}
}3.3 跨分片分页的正确处理
跨分片分页是分库分表中最难处理的问题之一。
/**
* 跨分片分页的正确实现
* 思路:每个分片多查 offset+limit 行,在应用层归并排序后取前 limit 行
*/
@Service
public class OrderPageService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 带分片键的分页(推荐,精确路由到1个分片)
*/
public Page<Order> pageByUserId(Long userId, int page, int size) {
int offset = (page - 1) * size;
// 精确路由到1个分片,分页完全正确
List<Order> orders = jdbcTemplate.query(
"SELECT * FROM orders WHERE user_id = ? " +
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
ROW_MAPPER, userId, size, offset
);
Long total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE user_id = ?",
Long.class, userId
);
return new Page<>(orders, total, page, size);
}
/**
* 全量跨分片分页(后台用,页数不大时可用)
* ShardingSphere内部会从每个分片取 offset+limit 行,
* 然后在内存中排序取前 limit 行
*
* 注意:offset很大时,内存中会有 256 * (offset+limit) 行数据,慎用!
*/
public Page<Order> pageAll(int page, int size) {
int offset = (page - 1) * size;
if (offset > 10000) {
throw new BusinessException("后台查询不支持翻页超过500页");
}
// ShardingSphere会自动处理跨分片分页(但内存消耗大)
List<Order> orders = jdbcTemplate.query(
"SELECT * FROM orders ORDER BY created_at DESC LIMIT ? OFFSET ?",
ROW_MAPPER, size, offset
);
return new Page<>(orders, -1L, page, size); // 总数不计算(代价太大)
}
}3.4 需要聚合查询的场景:用Elasticsearch替代
/**
* 对于需要跨分片聚合的查询(如统计、搜索),
* 应该将数据同步到ES,由ES负责聚合计算
*/
@Service
public class OrderAnalyticsService {
@Autowired
private RestHighLevelClient esClient;
/**
* 错误做法:直接在分库分表上做聚合
* SELECT user_id, COUNT(*) as cnt FROM orders
* WHERE created_at > '2022-01-01' GROUP BY user_id ORDER BY cnt DESC LIMIT 10
* → 全路由到256个分片,内存中归并 → 超时
*/
/**
* 正确做法:通过ES做聚合查询
*/
public List<UserOrderStats> topOrderUsers(LocalDate from, int topN) throws Exception {
SearchRequest request = new SearchRequest("orders_index");
SearchSourceBuilder source = new SearchSourceBuilder();
// 时间范围过滤
source.query(QueryBuilders.rangeQuery("created_at")
.gte(from.toString()));
// 按user_id分组统计
source.aggregation(
AggregationBuilders.terms("by_user")
.field("user_id")
.size(topN)
.order(BucketOrder.count(false)) // 按订单数降序
);
source.size(0); // 不需要原始文档,只要聚合结果
request.source(source);
SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
Terms terms = response.getAggregations().get("by_user");
return terms.getBuckets().stream()
.map(bucket -> new UserOrderStats(
Long.parseLong(bucket.getKeyAsString()),
bucket.getDocCount()
))
.collect(Collectors.toList());
}
}四、踩坑实录
坑1:分片键选择不当,数据倾斜
错误的分片键:user_type(只有A、B两种值)
user_type = 'A': 80%的数据 → 一半分片大,一半分片小
user_type = 'B': 20%的数据
正确的分片键应该:
1. 高基数(很多不同值,如user_id、order_id)
2. 均匀分布(避免热点集中)
3. 大多数查询都带这个字段
最佳实践:用用户ID或主键ID作为分片键检查数据倾斜的SQL:
-- 检查各分片的数据量(登到每个分片机器上执行)
SELECT table_name, table_rows, data_length/1024/1024 AS data_mb
FROM information_schema.TABLES
WHERE table_schema = 'orders_db0'
AND table_name LIKE 'orders_%'
ORDER BY table_rows DESC;
-- 如果某个分片比其他分片数据量多3倍以上,说明分片键分布不均坑2:跨分片JOIN不支持
-- ShardingSphere不支持跨不同分片规则的表JOIN
-- 例如:orders(分片键:user_id)JOIN order_items(分片键:order_id)
SELECT o.*, oi.product_id
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = 123;
-- 报错(可能):
-- ShardingSphere不支持跨不同逻辑表的binding table join
-- 或者全路由导致性能极差解决方案:
// 方案1:在应用层做JOIN(代价:多一次DB查询)
public OrderDetail getOrderDetail(Long userId, Long orderId) {
// 第一次查询:精确路由
Order order = orderMapper.findByUserIdAndOrderId(userId, orderId);
// 第二次查询:精确路由(order_items的分片键是order_id)
List<OrderItem> items = orderItemMapper.findByOrderId(orderId);
return new OrderDetail(order, items);
}
// 方案2:绑定表(Binding Table)- 确保orders和order_items路由到相同分片
// 配置:两个表用相同的分片算法,保证关联数据在同一个分片
// orders: db${user_id%16}.orders_${user_id%16}
// order_items: db${user_id%16}.order_items_${user_id%16}
// 此时JOIN只在单个分片内执行坑3:NOT IN / NOT EXISTS / 子查询在分库分表中的问题
-- 子查询在分库分表中路由复杂,可能报错或全路由
SELECT * FROM orders
WHERE user_id NOT IN (
SELECT user_id FROM blacklist WHERE type = 'FRAUD'
);
-- 报错或性能极差:
-- blacklist表可能没有分片,导致跨实例查询失败解决方案:
// 方案:先查blacklist(小表,不分片),在应用层过滤
List<Long> fraudUserIds = blacklistMapper.selectFraudUserIds();
// fraudUserIds通常很少(几百个),可以缓存起来
// 再按业务需要精确查询orders(带分片键)
List<Order> orders = orderMapper.findByUserIds(validUserIds);五、总结与延伸
分库分表的核心原则:
选好分片键:分片键要满足高基数、均匀分布、大多数查询携带。通常是用户ID或订单ID。
全路由是毒药:任何不带分片键的查询都会变成全路由,性能灾难。管理后台的查询必须限制深度,或者把管理查询迁移到数据仓库/ES。
跨分片聚合不能依赖数据库:统计分析类查询必须走数据仓库(Hive/ClickHouse)或ES,不要让分库分表承担OLAP工作。
分库分表的代价:
- 无法使用外键
- 跨分片事务变成分布式事务
- 跨分片JOIN要在应用层做
- 运维复杂度大幅提升
- 扩容(resharding)需要数据迁移
最后一句话:分库分表是必要之恶,能不用就不用。如果必须用,提前规划好分片键,后期修改的代价极大。
