问题
分库分表后如何解决跨库查询?
答案
1. 核心挑战
分库分表后,原本在单库中通过 JOIN、GROUP BY、ORDER BY 等操作轻松完成的查询,现在需要跨多个数据库执行,面临以下问题:
- 无法使用数据库的JOIN:数据分散在不同的库表中
- 分页查询困难:全局排序需要查询所有分片
- 聚合统计复杂:COUNT、SUM等需要汇总多个分片的结果
- 性能下降:需要访问多个数据源,增加网络开销
2. 主要解决方案
2.1 应用层聚合(最常用)
原理:在应用层查询所有相关分片,然后在内存中合并结果
实现示例:
// 场景:查询用户的所有订单并按时间排序
public class CrossShardQueryService {
@Autowired
private List<DataSource> shardDataSources;
// 跨库分页查询
public List<Order> queryOrders(Long userId, int page, int size) {
List<Order> allOrders = new ArrayList<>();
// 1. 并行查询所有分片
List<CompletableFuture<List<Order>>> futures = shardDataSources.stream()
.map(ds -> CompletableFuture.supplyAsync(() ->
queryFromShard(ds, userId, page * size) // 每个分片多取一些数据
))
.collect(Collectors.toList());
// 2. 等待所有查询完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// 3. 合并结果
futures.forEach(f -> allOrders.addAll(f.join()));
// 4. 内存排序
allOrders.sort(Comparator.comparing(Order::getCreateTime).reversed());
// 5. 内存分页
return allOrders.stream()
.skip(page * size)
.limit(size)
.collect(Collectors.toList());
}
// 跨库聚合查询
public OrderStatistics queryStatistics(String date) {
List<OrderStatistics> shardStats = shardDataSources.parallelStream()
.map(ds -> queryStatsFromShard(ds, date))
.collect(Collectors.toList());
// 聚合统计结果
return OrderStatistics.builder()
.totalAmount(shardStats.stream()
.map(OrderStatistics::getTotalAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add))
.totalCount(shardStats.stream()
.mapToLong(OrderStatistics::getTotalCount)
.sum())
.build();
}
}
优点:实现简单,灵活性高 缺点:需要查询所有分片,数据量大时性能差 适用场景:数据量不大、分片数量有限的场景
2.2 全局表(字典表冗余)
原理:在每个分库中都保存一份完整的字典表数据
实现示例:
-- 每个分片数据库都保存完整的字典表
-- db_0, db_1, db_2, db_3 都有这些表:
CREATE TABLE dict_product_category (
id INT PRIMARY KEY,
name VARCHAR(50),
parent_id INT
);
CREATE TABLE dict_region (
id INT PRIMARY KEY,
name VARCHAR(50),
level TINYINT
);
-- 订单表只在各自分片中
CREATE TABLE order_0 (
order_id BIGINT,
user_id BIGINT,
category_id INT, -- 可以直接JOIN dict_product_category
region_id INT -- 可以直接JOIN dict_region
);
优点:避免跨库JOIN,查询性能好 缺点:数据冗余,更新时需要同步所有分片 适用场景:变更频率低的字典表、配置表
2.3 数据冗余(宽表设计)
原理:在分片表中冗余常用的关联字段
实现示例:
// 原始设计(需要跨库JOIN)
@Table("order")
public class Order {
private Long orderId;
private Long userId;
private Long productId; // 需要JOIN product表获取产品名称
}
// 冗余设计(避免跨库JOIN)
@Table("order")
public class Order {
private Long orderId;
private Long userId;
private Long productId;
private String productName; // 冗余字段
private BigDecimal productPrice; // 冗余字段
private String categoryName; // 冗余字段
}
// 创建订单时同步冗余数据
public void createOrder(OrderDTO dto) {
Product product = productService.getById(dto.getProductId());
Order order = Order.builder()
.orderId(generateId())
.userId(dto.getUserId())
.productId(product.getId())
.productName(product.getName()) // 冗余
.productPrice(product.getPrice()) // 冗余
.categoryName(product.getCategoryName()) // 冗余
.build();
orderRepository.save(order);
}
优点:查询性能好,无需跨库 缺点:数据冗余,存在一致性问题 适用场景:读多写少、允许短暂数据不一致的场景
2.4 数据同步到查询库(CQRS)
原理:通过binlog、消息队列等将数据同步到专门的查询库(如ES、ClickHouse)
实现示例:
// 1. 监听binlog将数据同步到ES
@Component
public class OrderBinlogListener {
@Autowired
private ElasticsearchClient esClient;
@BinlogListener(table = "order_*")
public void onOrderChange(BinlogEvent event) {
Order order = event.getData();
// 同步到ES
OrderDocument doc = OrderDocument.builder()
.orderId(order.getOrderId())
.userId(order.getUserId())
.productName(order.getProductName())
.amount(order.getAmount())
.createTime(order.getCreateTime())
.build();
esClient.index("order", doc);
}
}
// 2. 从ES查询
@Service
public class OrderSearchService {
@Autowired
private ElasticsearchClient esClient;
// 复杂查询都走ES
public List<Order> search(OrderQuery query) {
BoolQuery boolQuery = BoolQuery.of(b -> b
.must(m -> m.range(r -> r
.field("createTime")
.gte(query.getStartTime())
.lte(query.getEndTime())))
.must(m -> m.term(t -> t
.field("userId")
.value(query.getUserId())))
);
return esClient.search(boolQuery, Order.class);
}
}
优点:支持复杂查询,性能好 缺点:架构复杂,存在数据延迟 适用场景:复杂查询多、对实时性要求不高的场景
2.5 ER分片(关联数据在同一分片)
原理:将有关联关系的数据路由到同一个分片
实现示例:
// 用户和订单按相同的分片键(userId)分片
public class ERShardingStrategy {
// 用户表分片
public String routeUser(Long userId) {
int shardIndex = Math.abs(userId.hashCode() % 4);
return "db_" + shardIndex;
}
// 订单表使用相同的分片策略
public String routeOrder(Long userId) {
// 使用userId而不是orderId作为分片键
int shardIndex = Math.abs(userId.hashCode() % 4);
return "db_" + shardIndex;
}
}
// 这样同一用户的订单和用户信息在同一个库,可以直接JOIN
@Service
public class UserOrderService {
public UserOrderVO getUserWithOrders(Long userId) {
// 路由到同一个库
String db = shardingStrategy.routeUser(userId);
// 可以在同一个库内JOIN
return jdbcTemplate.queryForObject(
"SELECT u.*, o.* FROM user u " +
"LEFT JOIN order o ON u.user_id = o.user_id " +
"WHERE u.user_id = ?",
new Object[]{userId},
UserOrderVO.class
);
}
}
优点:避免跨库JOIN,保持关联查询能力 缺点:分片键选择受限,可能导致数据倾斜 适用场景:有明确父子关系的业务(如用户-订单、商户-商品)
3. 不同场景的解决方案选择
| 查询类型 | 推荐方案 | 说明 |
|---|---|---|
| 按分片键精确查询 | 直接路由 | 最高效,避免跨库 |
| 关联字典表 | 全局表 | 字典表在每个分片都有副本 |
| 关联主表 | ER分片 | 关联数据在同一分片 |
| 复杂查询/全文搜索 | 同步到ES | 利用ES的强大查询能力 |
| 统计分析 | 同步到ClickHouse | OLAP场景,数据仓库 |
| 少量数据聚合 | 应用层聚合 | 简单直接 |
| 实时性要求高 | 数据冗余 | 避免关联查询 |
4. 最佳实践
4.1 查询优化原则
// ✅ 好的实践:尽量带上分片键
public List<Order> queryOrders(Long userId) {
// 可以精确路由到某个分片
return orderRepository.findByUserId(userId);
}
// ❌ 避免:不带分片键的全局查询
public List<Order> queryOrdersByProductId(Long productId) {
// 需要查询所有分片,性能差
return orderRepository.findByProductId(productId);
}
// ✅ 解决方案:冗余userId或使用ES
@Table("order")
public class Order {
private Long orderId;
private Long userId; // 分片键
private Long productId;
private Long sellerId; // 冗余卖家ID,便于按卖家查询
}
4.2 分页查询优化
public class CrossShardPagingOptimization {
// ❌ 错误做法:从每个分片取全部数据
public List<Order> badPaging(int page, int size) {
List<Order> allOrders = new ArrayList<>();
for (DataSource ds : shardDataSources) {
// 每个分片都取全部数据,内存占用大
allOrders.addAll(queryAll(ds));
}
return allOrders.stream()
.skip(page * size)
.limit(size)
.collect(Collectors.toList());
}
// ✅ 正确做法:从每个分片取 (page + 1) * size 条数据
public List<Order> goodPaging(int page, int size) {
int fetchSize = (page + 1) * size; // 关键优化
List<Order> allOrders = new ArrayList<>();
for (DataSource ds : shardDataSources) {
// 每个分片只取需要的数据量
allOrders.addAll(queryTopN(ds, fetchSize));
}
return allOrders.stream()
.sorted(Comparator.comparing(Order::getCreateTime).reversed())
.skip(page * size)
.limit(size)
.collect(Collectors.toList());
}
}
5. 核心要点总结
- 尽量避免跨库查询,在设计阶段就要考虑分片键的选择
- 字典表使用全局表,在每个分片都保存一份完整数据
- 关联查询优先考虑ER分片,将关联数据放在同一分片
- 复杂查询使用CQRS,同步到ES或ClickHouse等专用查询引擎
- 数据冗余是常用手段,用空间换时间,但要注意一致性问题
- 应用层聚合适合小数据量,大数据量需要从架构上解决
- 分页查询要优化,不要从每个分片取全部数据,只取需要的topN