复杂业务场景(顺风车)的分库分表如何设计?
业务场景
顺风车平台订单数据,日均订单500万,历史订单超过20亿条。
核心挑战:不同于电商订单只需按用户查询,顺风车需要多维度查询:
| 角色 | 查询维度 |
|---|---|
| 乘客 | 按乘客ID查 |
| 车主 | 按车主ID查 |
| 客服 | 按订单ID查 |
| 城市运营 | 按城市+时间查 |
问题:无论选择哪个字段作为Sharding Key,其他维度都需要跨分片查询!
方案分析
方案一:选择乘客ID
分片键:passenger_id
乘客查订单 → 精准路由 ✅
车主查订单 → 跨分片扫描 ❌
方案二:选择车主ID
分片键:driver_id
车主查订单 → 精准路由 ✅
乘客查订单 → 跨分片扫描 ❌
方案三:双写冗余
orders_by_passenger(按乘客ID分片)
orders_by_driver(按车主ID分片)
乘客查订单 → 查orders_by_passenger ✅
车主查订单 → 查orders_by_driver ✅
缺点:数据冗余,一致性维护复杂
推荐方案:主表 + ES索引
架构图
┌─────────────────────────────────────────────────────────┐
│ 写入流程 │
├─────────────────────────────────────────────────────────┤
│ │
│ 创建订单 → MySQL分库分表(按乘客ID) → 同步到ES │
│ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 查询流程 │
├─────────────────────────────────────────────────────────┤
│ │
│ 乘客查询 → MySQL精准路由 │
│ 车主查询 → ES查询 → 获取订单ID → MySQL回源 │
│ 运营查询 → ES聚合 │
│ │
└─────────────────────────────────────────────────────────┘
核心设计
1. MySQL主表(按乘客ID分片)
CREATE TABLE orders (
id BIGINT PRIMARY KEY, -- 包含分片基因
passenger_id BIGINT NOT NULL, -- 分片键
driver_id BIGINT NOT NULL,
city_id INT NOT NULL,
order_status TINYINT,
create_time DATETIME,
-- 其他字段
KEY idx_passenger (passenger_id),
KEY idx_create_time (create_time)
);
2. 订单ID设计(基因法)
public long generateOrderId(long passengerId) {
long snowflakeId = snowflake.nextId();
// 将passengerId的分片信息编入订单ID
int gene = (int) (passengerId % 1024);
return (snowflakeId >> 10 << 10) | gene;
}
好处:通过订单ID即可路由到正确分片。
3. ES索引结构
{
"mappings": {
"properties": {
"order_id": { "type": "long" },
"passenger_id": { "type": "long" },
"driver_id": { "type": "long" },
"city_id": { "type": "integer" },
"order_status": { "type": "integer" },
"create_time": { "type": "date" },
"start_location": { "type": "geo_point" },
"end_location": { "type": "geo_point" }
}
}
}
查询实现
乘客查询(走MySQL)
public List<Order> getPassengerOrders(Long passengerId) {
// 直接路由到对应分片
return orderMapper.findByPassengerId(passengerId);
}
车主查询(ES + MySQL回源)
public List<Order> getDriverOrders(Long driverId, int page, int size) {
// 1. ES查询订单ID
SearchRequest request = new SearchRequest("orders");
request.source(new SearchSourceBuilder()
.query(QueryBuilders.termQuery("driver_id", driverId))
.sort("create_time", SortOrder.DESC)
.from(page * size).size(size)
.fetchSource(new String[]{"order_id", "passenger_id"}, null));
SearchResponse response = esClient.search(request);
// 2. 根据订单ID批量回源MySQL
List<Long> orderIds = extractOrderIds(response);
return orderMapper.findByIds(orderIds);
}
运营查询(纯ES)
public DashboardData getCityStats(Integer cityId, LocalDate date) {
SearchRequest request = new SearchRequest("orders");
request.source(new SearchSourceBuilder()
.query(QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("city_id", cityId))
.must(QueryBuilders.rangeQuery("create_time")
.gte(date.atStartOfDay())
.lt(date.plusDays(1).atStartOfDay())))
.aggregation(AggregationBuilders.count("total"))
.aggregation(AggregationBuilders.sum("amount").field("order_amount"))
.size(0));
return parseResponse(esClient.search(request));
}
数据同步
Canal + Kafka实时同步
MySQL binlog → Canal → Kafka → ES Consumer → ES
@KafkaListener(topics = "order-binlog")
public void syncToES(String message) {
BinlogEvent event = JSON.parseObject(message, BinlogEvent.class);
if ("INSERT".equals(event.getType()) || "UPDATE".equals(event.getType())) {
Order order = event.getData();
ESOrder esOrder = convertToESOrder(order);
esClient.index(new IndexRequest("orders")
.id(order.getId().toString())
.source(JSON.toJSONString(esOrder), XContentType.JSON));
}
}
延迟处理
ES同步有延迟(通常秒级),对于车主端可以接受:
- 刚完成的订单会有1-2秒延迟出现
- 实时性要求高的场景,可先查缓存
复杂查询场景
附近的顺风车(地理位置)
public List<Order> findNearbyOrders(double lat, double lon, double distance) {
SearchRequest request = new SearchRequest("orders");
request.source(new SearchSourceBuilder()
.query(QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("order_status", 1)) // 待接单
.must(QueryBuilders.geoDistanceQuery("start_location")
.point(lat, lon)
.distance(distance, DistanceUnit.KILOMETERS)))
.sort(SortBuilders.geoDistanceSort("start_location", lat, lon)
.order(SortOrder.ASC)));
return extractOrders(esClient.search(request));
}
订单路径匹配
// 车主发布行程后,匹配顺路乘客
public List<Order> matchPassengers(Route driverRoute) {
// 使用ES的geo_shape查询
// 匹配起点和终点都在车主路线附近的订单
// 具体实现较复杂,需要路径规划算法配合
}
方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 单一分片键 | 简单 | 跨分片查询多 |
| 双写冗余 | 两个维度都快 | 数据翻倍,一致性难 |
| 主表+ES | 灵活,支持复杂查询 | ES运维成本 |
推荐选择
- 小规模:单一分片键 + 广播查询
- 中等规模:双写冗余
- 大规模/复杂查询:主表 + ES(推荐)
面试答题框架
场景特点:多维度查询(乘客/车主/城市)
核心挑战:
- 无论选哪个分片键,其他维度都需跨分片
解决方案:
1. MySQL按乘客ID分片(主要写入维度)
2. ES索引支持多维度查询
3. 基因法支持订单ID路由
查询策略:
- 乘客查询 → MySQL精准路由
- 车主/运营查询 → ES → MySQL回源
数据同步:
- Canal监听binlog
- Kafka异步同步到ES
总结
| 设计点 | 方案 |
|---|---|
| 分片键 | 乘客ID(写入主维度) |
| 订单ID | 雪花ID + 基因法 |
| 车主查询 | ES索引 |
| 运营统计 | ES聚合 |
| 数据同步 | Canal + Kafka |