Skip to content

分库分表查询:跨分片操作的解决方案

分库分表后,最头疼的问题来了:跨分片查询

本来一条 SQL 能搞定的事,现在要查多个库、多张表,再合并结果。

今天,我们来解决这个问题。


跨分片查询的挑战

场景一:分页查询

sql
-- 原始需求:查询第 100 页,每页 20 条
-- 分成 4 个分片

-- 错误做法:每个分片都查
SELECT * FROM orders LIMIT 100, 20  -- 每个分片查 120 条
-- 合并后再取第 100 页?逻辑复杂

-- 正确做法:限制每个分片的返回量
-- 如果能接受每页显示部分分片的数据
SELECT * FROM orders LIMIT 25  -- 每个分片只查 25 条
-- 合并后最多 100 条,取前 20 条

场景二:聚合查询

sql
-- 原始需求:统计订单总数
-- 分成 4 个分片

-- 正确做法:各分片分别统计,汇总
分片 0: SELECT COUNT(*) FROM orders_0  → 25
分片 1: SELECT COUNT(*) FROM orders_1  → 30
分片 2: SELECT COUNT(*) FROM orders_2  → 28
分片 3: SELECT COUNT(*) FROM orders_3  → 27
总计: 25 + 30 + 28 + 27 = 110

场景三:JOIN 查询

sql
-- 分片键不同,无法跨分片 JOIN
-- orders 表按 user_id 分片
-- products 表按 product_id 分片

-- SELECT o.*, p.name FROM orders o
-- JOIN products p ON o.product_id = p.id
-- WHERE o.user_id = 1

-- 解决方案:应用层分步查询
List<Order> orders = queryOrdersByUserId(userId);
List<Long> productIds = orders.stream().map(Order::getProductId).collect(toList());
List<Product> products = queryProductsByIds(productIds);
// 应用层 JOIN

跨分片查询策略

策略一:路由到单分片

这是最理想的情况,查询能直接路由到单个分片。

java
public class SingleShardQuery {
    /**
     * 按分片键查询,直接路由到单个分片
     */
    public List<Order> queryByUserId(long userId) {
        // 根据 user_id 计算分片
        int shardIndex = (int) (userId % SHARD_COUNT);
        DataSource ds = getDataSource(shardIndex);
        String tableName = getTableName("orders", shardIndex);

        String sql = String.format("SELECT * FROM %s WHERE user_id = ?", tableName);
        return jdbcTemplate.query(sql, userId);
    }
}

策略二:广播查询

需要查询所有分片,然后合并结果。

java
public class BroadcastQuery {
    /**
     * 查询所有分片,合并结果
     */
    public List<Order> queryAll() {
        List<Order> allOrders = new ArrayList<>();

        for (int i = 0; i < SHARD_COUNT; i++) {
            DataSource ds = getDataSource(i);
            String tableName = getTableName("orders", i);
            String sql = String.format("SELECT * FROM %s", tableName);

            List<Order> orders = jdbcTemplate.query(sql);
            allOrders.addAll(orders);
        }

        return allOrders;
    }

    /**
     * COUNT 查询
     */
    public long countAll() {
        long total = 0;
        for (int i = 0; i < SHARD_COUNT; i++) {
            String sql = String.format("SELECT COUNT(*) FROM orders_%d", i);
            long count = jdbcTemplate.queryForObject(sql, Long.class);
            total += count;
        }
        return total;
    }
}

策略三:聚合查询

java
public class AggregationQuery {
    /**
     * SUM 查询
     */
    public BigDecimal sumAmount(long userId) {
        BigDecimal total = BigDecimal.ZERO;

        for (int i = 0; i < SHARD_COUNT; i++) {
            String sql = String.format(
                "SELECT SUM(amount) FROM orders_%d WHERE user_id = ?", i);
            BigDecimal sum = jdbcTemplate.queryForObject(sql, BigDecimal.class, userId);
            if (sum != null) {
                total = total.add(sum);
            }
        }

        return total;
    }

    /**
     * GROUP BY 查询
     */
    public Map<String, Long> groupByStatus() {
        Map<String, Long> result = new HashMap<>();

        for (int i = 0; i < SHARD_COUNT; i++) {
            String sql = String.format(
                "SELECT status, COUNT(*) FROM orders_%d GROUP BY status", i);
            List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);

            for (Map<String, Object> row : rows) {
                String status = (String) row.get("status");
                Long count = (Long) row.get("COUNT(*)");
                result.merge(status, count, Long::sum);
            }
        }

        return result;
    }
}

分页查询实现

方案一:深度分页限制

java
public class PaginationQuery {
    /**
     * 分页查询(深度分页有限制)
     */
    public Page<Order> paginate(int pageNum, int pageSize) {
        // 限制最大页码,避免性能问题
        if (pageNum > MAX_PAGE) {
            throw new IllegalArgumentException("页码不能超过 " + MAX_PAGE);
        }

        List<Order> allOrders = new ArrayList<>();
        for (int i = 0; i < SHARD_COUNT; i++) {
            String sql = String.format(
                "SELECT * FROM orders_%d ORDER BY id LIMIT ?",
                i, pageSize * MAX_SHARD
            );
            List<Order> orders = jdbcTemplate.query(sql);
            allOrders.addAll(orders);
        }

        // 排序
        allOrders.sort(Comparator.comparing(Order::getId));

        // 分页
        int offset = (pageNum - 1) * pageSize;
        List<Order> pageOrders = allOrders.stream()
            .skip(offset)
            .limit(pageSize)
            .collect(Collectors.toList());

        return new Page<>(pageOrders, pageNum, pageSize);
    }
}

方案二:游标分页(推荐)

java
public class CursorPagination {
    /**
     * 游标分页查询
     */
    public CursorPage<Order> cursorPage(Long lastId, int pageSize) {
        List<Order> allOrders = new ArrayList<>();

        for (int i = 0; i < SHARD_COUNT; i++) {
            String sql;
            if (lastId == null) {
                sql = String.format(
                    "SELECT * FROM orders_%d ORDER BY id LIMIT ?", i, pageSize);
            } else {
                sql = String.format(
                    "SELECT * FROM orders_%d WHERE id > ? ORDER BY id LIMIT ?",
                    i, lastId, pageSize);
            }
            List<Order> orders = jdbcTemplate.query(sql, lastId == null ? pageSize : lastId, pageSize);
            allOrders.addAll(orders);
        }

        // 全局排序
        allOrders.sort(Comparator.comparing(Order::getId));

        // 取一页数据
        List<Order> pageOrders = allOrders.stream()
            .limit(pageSize)
            .collect(Collectors.toList());

        // 计算下一页游标
        Long nextCursor = null;
        if (pageOrders.size() == pageSize) {
            nextCursor = pageOrders.get(pageOrders.size() - 1).getId();
        }

        return new CursorPage<>(pageOrders, nextCursor);
    }
}

跨分片 JOIN 实现

异构查询

两个表分片键不同,需要在应用层关联。

java
public class CrossShardJoin {
    /**
     * 查询用户订单及其商品信息(异构分片)
     */
    public List<OrderDetail> queryOrderDetails(long userId) {
        // 1. 查询用户的订单
        List<Order> orders = queryOrdersByUserId(userId);

        // 2. 收集商品 ID
        List<Long> productIds = orders.stream()
            .map(Order::getProductId)
            .distinct()
            .collect(Collectors.toList());

        // 3. 查询商品信息(按 product_id 计算分片)
        Map<Long, Product> productMap = queryProductsByIds(productIds);

        // 4. 应用层 JOIN
        return orders.stream()
            .map(order -> {
                OrderDetail detail = new OrderDetail();
                detail.setOrder(order);
                detail.setProduct(productMap.get(order.getProductId()));
                return detail;
            })
            .collect(Collectors.toList());
    }

    /**
     * 按 product_id 查询商品(不同分片键)
     */
    private Map<Long, Product> queryProductsByIds(List<Long> productIds) {
        Map<Long, Product> result = new HashMap<>();

        // 按 product_id % 4 计算分片
        Map<Integer, List<Long>> groupedIds = productIds.stream()
            .collect(Collectors.groupingBy(id -> (int) (id % SHARD_COUNT)));

        for (Map.Entry<Integer, List<Long>> entry : groupedIds.entrySet()) {
            int shardIndex = entry.getKey();
            List<Long> ids = entry.getValue();

            String sql = String.format(
                "SELECT * FROM products_%d WHERE id IN (%s)",
                shardIndex, ids.stream().map(String::valueOf).collect(Collectors.joining(",")));
            List<Product> products = jdbcTemplate.query(sql);
            products.forEach(p -> result.put(p.getId(), p));
        }

        return result;
    }
}

最佳实践

场景解决方案
按分片键查询路由到单个分片
全表查询广播查询 + 合并
COUNT/SUM 查询各分片聚合 + 汇总
分页查询限制页码深度 或 游标分页
异构分片 JOIN应用层关联

一句话总结

跨分片查询的核心是:能路由到单分片就路由,不能就广播查询再合并。聚合查询各分片分别计算最后汇总,分页用游标替代深度页码。

基于 VitePress 构建