ShardingSphere 深度解析
面试官:你用过 ShardingSphere 吗?它是怎么工作的?
你:用过,ShardingSphere 是一个分布式数据库中间件,通过 SQL 解析 → 路由 → 改写 → 执行 → 归并的流程,对应用透明地实现分库分表、读写分离。
面试官:JDBC 模式和 Proxy 模式有什么区别?你们用的哪种?
这个追问考察你对架构选型的理解。能说清「两种模式的优缺点和适用场景」的候选人,才是真正用过。
链式追问一:ShardingSphere 架构模式
Section titled “链式追问一:ShardingSphere 架构模式”Q1:ShardingSphere 的三种接入模式有什么区别?如何选择?高频
Section titled “Q1:ShardingSphere 的三种接入模式有什么区别?如何选择?”三种接入模式:
| 模式 | 架构 | 性能 | 语言支持 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|---|
| ShardingSphere-JDBC | Java 库,应用内嵌,替换 DataSource | 最高(进程内调用) | 仅 Java | 低 | Java 应用,性能敏感 |
| ShardingSphere-Proxy | 独立部署的数据库代理,伪装成 MySQL | 较低(额外网络跳转) | 语言无关 | 高 | 多语言环境,DBA 管理 |
| ShardingSphere-Sidecar | K8s Sidecar 模式,Agent 代理 | 中 | 语言无关 | 中 | K8s 环境(实验阶段) |
架构对比:
ShardingSphere-JDBC 模式: 应用(Java) └─ ShardingSphere-JDBC(jar 包) ├─ 解析 SQL ├─ 路由到分片 └─ 直连数据库 → 性能最好(无网络跳转)
ShardingSphere-Proxy 模式: 应用(任意语言) └─ MySQL 协议 └─ ShardingSphere-Proxy(独立进程) ├─ 解析 SQL ├─ 路由到分片 └─ 连接数据库 → 语言无关,但多一层网络
ShardingSphere-Sidecar 模式(K8s): Pod ├─ 应用容器 └─ ShardingSphere-Sidecar 容器 └─ 代理数据库连接 → 云原生,自动化部署性能对比:
测试场景:单表查询,QPS 压测
直连 MySQL: QPS:10000 延迟:1ms
ShardingSphere-JDBC: QPS:9500(-5%) 延迟:1.05ms(+0.05ms)
ShardingSphere-Proxy: QPS:7500(-25%) 延迟:1.3ms(+0.3ms,网络跳转)
结论: - JDBC 模式性能损失最小(<10%) - Proxy 模式性能损失较大(20%-30%),但换来语言无关和运维便利选型建议:
选 ShardingSphere-JDBC: - Java 技术栈 - 性能敏感(如交易系统) - 运维资源有限(无需额外部署)
选 ShardingSphere-Proxy: - 多语言技术栈(Java、Go、Python 混用) - DBA 需要统一管理数据库连接 - 需要灰度切换(Proxy 层做流量控制)
选 ShardingSphere-Sidecar: - K8s 环境 - 追求云原生架构 - 实验阶段,生产慎用Q2:ShardingSphere 的 SQL 执行流程是什么?必考
Section titled “Q2:ShardingSphere 的 SQL 执行流程是什么?”完整执行流程:
应用发送 SQL: SELECT * FROM orders WHERE user_id = 123 AND status = 'PENDING'
↓
1. SQL 解析(Parse): └── 词法分析 + 语法分析 └── 生成 AST(抽象语法树) └── 识别表名(orders)、WHERE 条件(user_id=123, status='PENDING') └── 识别 SQL 类型(SELECT)
↓
2. 路由(Route): └── 提取分片键值(user_id=123) └── 应用分片算法 └── user_id % 4 = 3 → db_3 └── user_id % 16 = 11 → orders_11 └── 路由结果:db_3.orders_11
↓
3. 改写(Rewrite): └── 将逻辑 SQL 改写为物理 SQL └── SELECT * FROM orders → SELECT * FROM orders_11 └── WHERE user_id = 123 AND status = 'PENDING'(保持不变)
↓
4. 执行(Execute): └── 连接 db_3 数据源 └── 发送物理 SQL:SELECT * FROM orders_11 WHERE ... └── 数据库执行查询
↓
5. 归并(Merge): └── 如果路由到多个分片,合并结果集 └── 排序归并(ORDER BY) └── 分组归并(GROUP BY) └── 聚合归并(COUNT/SUM/AVG) └── 返回最终结果给应用跨分片查询的归并流程:
-- 查询:按 status 分组统计(无分片键)SELECT status, COUNT(*) FROM orders GROUP BY status;
-- 路由:广播到所有分片(db_0.orders_0, db_0.orders_1, ..., db_3.orders_15)
-- 各分片执行:-- db_0.orders_0: PENDING(100), PAID(200), SHIPPED(150)-- db_0.orders_1: PENDING(120), PAID(180), SHIPPED(160)-- ...(共 64 个分片)
-- 归并:-- PENDING = 100 + 120 + ... = 8000-- PAID = 200 + 180 + ... = 12000-- SHIPPED = 150 + 160 + ... = 10000
-- 返回应用:-- PENDING: 8000-- PAID: 12000-- SHIPPED: 10000归并策略详解:
| 归并类型 | 场景 | 实现方式 |
|---|---|---|
| 遍历归并 | 简单查询 | 遍历所有分片结果,直接返回 |
| 排序归并 | ORDER BY | 各分片返回有序结果,归并排序 |
| 分组归并 | GROUP BY | 各分片返回分组结果,合并统计 |
| 聚合归并 | COUNT/SUM/AVG/MAX/MIN | 各分片返回聚合值,合并计算 |
| 分页归并 | LIMIT OFFSET | 各分片返回分页结果,全局分页 |
链式追问二:分片算法与配置
Section titled “链式追问二:分片算法与配置”Q3:ShardingSphere 支持哪些分片算法?如何选择?高频
Section titled “Q3:ShardingSphere 支持哪些分片算法?如何选择?”内置分片算法分类:
| 算法类型 | 适用场景 | 分片规则 | 优点 | 缺点 |
|---|---|---|---|---|
| 取模分片 | 数据均匀分布 | user_id % 4 | 简单;均匀 | 扩容需迁移数据 |
| 范围分片 | 时间/数值范围 | create_time 按月分 | 扩容简单;历史数据归档 | 热点问题(最新数据热) |
| 哈希分片 | 字符串分片键 | MD5(user_id) % 4 | 分布均匀 | 无法范围查询 |
| 复合分片 | 多个分片键 | user_id + order_date | 灵活 | 复杂 |
配置示例:
shardingsphere: rules: sharding: sharding-algorithms: # 1. 取模分片(最常用) db-inline: type: INLINE props: algorithm-expression: db_${user_id % 4}
table-inline: type: INLINE props: algorithm-expression: orders_${user_id % 16}
# 2. 范围分片(按时间) order-range: type: INTERVAL props: datetime-pattern: yyyy-MM-dd HH:mm:ss datetime-lower: 2024-01-01 00:00:00 datetime-upper: 2024-12-31 23:59:59 sharding-suffix-pattern: yyyyMM datetime-interval-amount: 1 datetime-interval-unit: MONTHS # 结果:orders_202401, orders_202402, ..., orders_202412
# 3. 哈希分片(字符串分片键) user-hash: type: HASH_MOD props: sharding-count: 4 # MD5(user_id) % 4
# 4. 复合分片(多个分片键) complex-algorithm: type: COMPLEX_INLINE props: sharding-columns: user_id,order_date algorithm-expression: db_${user_id % 4}_${order_date.format('yyyyMM')}分片算法选择建议:
场景一:按用户 ID 分(电商订单) 分片键:user_id(数值) 算法:取模分片 配置:user_id % 4(库) + user_id % 16(表)
场景二:按时间分(日志、流水) 分片键:create_time(时间) 算法:范围分片(按月/按日) 优点:历史数据归档简单(直接删除旧分片)
场景三:按字符串分(用户名、设备ID) 分片键:username(字符串) 算法:哈希分片 配置:MD5(username) % 4
场景四:多维分片(用户 + 时间) 分片键:user_id + order_date 算法:复合分片 优点:按用户查询 + 按时间范围查询都高效自定义分片算法:
// 实现自定义算法(如按城市路由)public class CityShardingAlgorithm implements StandardShardingAlgorithm<String> {
private Map<String, String> cityToDb = Map.of( "Beijing", "db_0", "Shanghai", "db_0", "Guangzhou", "db_1", "Shenzhen", "db_1" );
@Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<String> shardingValue) { String city = shardingValue.getValue(); return cityToDb.getOrDefault(city, "db_2"); // 其他城市 → db_2 }
@Override public void init(Properties props) { // 初始化配置 }}# 配置自定义算法shardingsphere: rules: sharding: sharding-algorithms: city-algorithm: type: CityShardingAlgorithm props: # 自定义参数Q4:分布式主键如何配置?雪花算法的时钟回拨问题如何解决?高频
Section titled “Q4:分布式主键如何配置?雪花算法的时钟回拨问题如何解决?”ShardingSphere 内置主键生成器:
| 生成器 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| SNOWFLAKE | 雪花算法 | 趋势递增;高性能 | 时钟回拨风险 |
| UUID | UUID | 全局唯一 | 无序;太长 |
| LEAF | 美团 Leaf | 高性能;无时钟依赖 | 需要部署 Leaf 服务 |
雪花算法配置:
shardingsphere: rules: sharding: tables: orders: actual-data-nodes: db_${0..3}.orders_${0..15}
# 分布式主键配置 key-generate-strategy: column: id # 哪个列生成分布式主键 key-generator-name: snowflake # 使用雪花算法
key-generators: snowflake: type: SNOWFLAKE props: worker-id: ${WORKER_ID:1} # 机器 ID(每个实例不同!) max-tolerate-time-difference-milliseconds: 10 # 最大容忍时钟回拨 10ms雪花算法结构:
64位 long 型整数: ┌──────────────────────────────────────────────────────────────┐ │ 0 │ 41位时间戳(ms) │ 10位机器ID │ 12位序列号 │ └──────────────────────────────────────────────────────────────┘ │ │ │ └─ 同一毫秒内 4096 个ID │ │ └──────────────── 1024 台机器 │ └───────────────────────────────────── 约 69 年 └─────────────────────────────────────────────── 符号位(始终 0)
示例: 时间戳:2024-01-01 12:00:00.000 机器ID:1 序列号:0
生成的 ID:17365232559104时钟回拨问题:
问题场景: 系统时间被 NTP 同步回调(如从 12:00:01 调到 11:59:59) → 可能生成与之前重复的 ID
ShardingSphere 的处理: 1. 检测时钟回拨 2. 如果回拨时间 < max-tolerate-time-difference-milliseconds(默认 10ms) → 等待时钟追上 3. 如果回拨时间 > max-tolerate-time-difference-milliseconds → 抛出异常(拒绝生成 ID)解决方案:
方案一:禁用 NTP 自动同步(推荐) # 关闭 NTP 自动同步 systemctl stop ntpd # 只允许手动同步 ntpdate -s time.nist.gov
方案二:容忍短时间回拨 max-tolerate-time-difference-milliseconds: 100 # 容忍 100ms 回拨
方案三:使用 Leaf(美团分布式ID生成服务) key-generator-name: leaf props: leaf: type: SEGMENT # 号段模式(无时钟依赖)worker-id 唯一性保障:
问题:worker-id 重复 → 可能生成重复 ID
解决方案:
方案一:配置文件硬编码(小规模) # 每个部署实例手动配置不同的 worker-id worker-id: 1 # 实例1 worker-id: 2 # 实例2
方案二:K8s StatefulSet(推荐) # 从 Pod 名称提取序号 # Pod: order-service-0, order-service-1, ... worker-id: ${HOSTNAME##*-}
方案三:Zookeeper 自动分配 key-generators: snowflake: type: SNOWFLAKE props: worker-id: zk # 从 ZK 自动分配链式追问三:分片策略与性能优化
Section titled “链式追问三:分片策略与性能优化”Q5:分表键查询 vs 非分表键查询性能差距有多大?如何优化?必考
Section titled “Q5:分表键查询 vs 非分表键查询性能差距有多大?如何优化?”性能对比:
-- 场景:64 张分片表(4 库 × 16 表)
-- 按分表键查询(精确路由)SELECT * FROM orders WHERE user_id = 123;-- → 路由到 1 个分片(db_3.orders_11)-- 性能:等同于单表查询,<10ms
-- 按非分表键查询(全分片广播)SELECT * FROM orders WHERE order_no = 'ORD20240101001';-- → 广播到所有 64 个分片-- → 串行或并行执行(取决于配置)-- 性能:64 个分片查询时间,约 100ms-500ms
-- 性能差距:10-50 倍优化方案:
方案一:业务设计避免非分表键查询
// 场景:用户查询订单// ❌ 不好:按 order_no 查询(非分表键)@GetMapping("/order")public Order getByOrderNo(@RequestParam String orderNo) { return orderService.getByOrderNo(orderNo); // 全分片查询}
// ✅ 好:要求前端同时传 user_id@GetMapping("/order")public Order getByOrderNo(@RequestParam String orderNo, @RequestParam Long userId) { return orderService.getByOrderNo(orderNo, userId); // 精确路由}
// SQL:SELECT * FROM orders WHERE user_id = 123 AND order_no = 'ORD20240101001';// → 先用 user_id 定位分片,再用 order_no 过滤方案二:将分片键编码进业务主键
// 将 user_id 编码进 order_nopublic String generateOrderNo(Long userId) { // order_no = 时间戳 + user_id后6位 + 序列号 String timestamp = DateUtil.format(new Date(), "yyyyMMddHHmmss"); String userIdPart = String.format("%06d", userId % 1000000); String sequence = String.format("%04d", getNextSequence()); return timestamp + userIdPart + sequence; // 结果:202401011200001234560001}
// 从 order_no 解析 user_idpublic Long parseUserId(String orderNo) { String userIdPart = orderNo.substring(14, 20); // 提取 user_id 部分 return Long.parseLong(userIdPart);}
// 查询时:public Order getByOrderNo(String orderNo) { Long userId = parseUserId(orderNo); // 解析 user_id return orderMapper.selectByUserIdAndOrderNo(userId, orderNo); // 精确路由}方案三:绑定表(Binding Table)
# 绑定表配置:orders 和 order_items 使用相同的分片规则shardingsphere: rules: sharding: binding-tables: - orders,order_items # 保证 JOIN 时同一用户的数据在同一分片
# 查询:订单 + 订单项SELECT o.*, oi.*FROM orders oJOIN order_items oi ON o.id = oi.order_idWHERE o.user_id = 123;
-- 路由:-- orders 和 order_items 都按 user_id 分片-- → 同一 user_id 的数据在同一分片-- → JOIN 在单分片内执行,无需跨分片 JOIN方案四:广播表(小表广播)
# 广播表:所有分片都存一份完整数据shardingsphere: rules: sharding: broadcast-tables: - regions # 地区表(数据量小,变化少)
# 查询:SELECT o.*, r.name as region_nameFROM orders oJOIN regions r ON o.region_id = r.idWHERE o.user_id = 123;
-- regions 表在所有分片都有完整数据-- → JOIN 在单分片内执行方案五:ES 辅助查询
// 场景:复杂搜索(按商品名、价格范围、地区等多条件)// 方案:将订单数据同步到 ES,用 ES 做搜索
// 1. 写入时双写@Transactionalpublic void createOrder(Order order) { orderMapper.insert(order); // 写 MySQL
// 异步写 ES CompletableFuture.runAsync(() -> { esClient.index("orders", order); });}
// 2. 搜索时用 ESpublic List<Order> searchOrders(String keyword, Integer minAmount, Integer maxAmount) { // ES 搜索,返回 order_id 列表 List<Long> orderIds = esClient.search("orders", keyword, minAmount, maxAmount);
// 再从 MySQL 批量查询(需要解析 user_id) return orderMapper.selectByIds(orderIds);}Q6:ShardingSphere 的 HINT 如何使用?什么场景需要强制路由?高频
Section titled “Q6:ShardingSphere 的 HINT 如何使用?什么场景需要强制路由?”HINT 强制路由场景:
场景一:强制读主库(写后立即读) 用户修改头像后,立即刷新页面 → 需要读主库,确保读到最新数据
场景二:强制路由到指定分片 数据迁移时,需要读取特定分片的数据 → 用 HINT 指定分片
场景三:读写分离 + 强制主库 某些业务必须读主库(如用户敏感操作) → 用 HINT 强制主库HINT 使用示例:
强制读主库:
// 方式一:HintManagerpublic User getCurrentUser(Long userId) { HintManager hintManager = HintManager.getInstance(); try { hintManager.setWriteRouteOnly(); // 强制走主库 return userMapper.findById(userId); } finally { hintManager.close(); // 必须关闭,否则影响后续查询 }}
// 方式二:SQL 注释(ShardingSphere 5.1+)SELECT /*+ SHARDINGSPHERE_HINT: WRITE_ROUTE_ONLY=true */ *FROM usersWHERE id = 123;强制路由到指定分片:
public List<Order> getOrdersFromShard0(Long userId) { HintManager hintManager = HintManager.getInstance(); try { // 强制路由到 db_0.orders_0 hintManager.addDatabaseShardingValue("orders", 0); hintManager.addTableShardingValue("orders", 0);
return orderMapper.selectAll(); } finally { hintManager.close(); }}强制读主库配置(读写分离):
shardingsphere: rules: readwrite-splitting: data-sources: myds: type: STATIC props: write-data-source-name: master read-data-source-names: slave0,slave1
# 代码中使用 HINT 强制主库@DS("master") // 或使用 HintManagerpublic User getCurrentUser(Long userId) { return userMapper.findById(userId);}链式追问四:生产最佳实践
Section titled “链式追问四:生产最佳实践”Q7:如何不停机迁移到 ShardingSphere 分库分表?实战
Section titled “Q7:如何不停机迁移到 ShardingSphere 分库分表?”经典方案:双写 + 数据同步 + 灰度切流:
阶段一:双写(新旧库同时写) 应用 → 写旧库(单库单表) → 写新库(分库分表,异步,允许失败)
阶段二:历史数据同步 旧库 → 数据迁移工具(DataX/Canal/ShardingSphere-Scaling)→ 新库 按时间分批迁移(如按月份)
阶段三:数据校验 对比新旧库数据(行数、抽样对比、checksum) 修复不一致数据
阶段四:切读(灰度) 1% 流量读新库 → 观察 → 10% → 50% → 100%
阶段五:停双写,下线旧库 只写新库 → 旧库归档双写代码示例:
@Servicepublic class OrderService {
@Autowired private OrderMapper oldOrderMapper; // 旧库
@Autowired private ShardingOrderMapper newOrderMapper; // 新分库分表
@Transactional public void placeOrder(Order order) { // 1. 写旧库(主流程,失败抛异常) oldOrderMapper.insert(order);
// 2. 写新库(异步,允许失败) CompletableFuture.runAsync(() -> { try { newOrderMapper.insert(order); } catch (Exception e) { log.error("写入新库失败", e); // 记录日志,后续补偿 compensationService.recordFailure(order); } }); }}数据迁移方案:
方案一:ShardingSphere-Scaling(推荐) 优点:在线迁移,支持全量 + 增量同步 步骤: 1. 配置源库和目标库 2. 启动 Scaling 任务 3. 自动同步历史数据 + 增量数据 4. 数据校验 5. 切流
方案二:DataX(全量迁移) 优点:阿里开源,性能好 缺点:只支持全量,增量需用 Canal
方案三:Canal(增量同步) 原理:监听 binlog,实时同步 适用:历史数据迁移后,用 Canal 同步增量切读灰度方案:
// 基于用户 ID 灰度public Order getOrder(Long orderId, Long userId) { if (userId % 100 < grayPercentage) { // grayPercentage 从 1 → 100 // 灰度用户:读新库 return shardingOrderMapper.findById(orderId); } else { // 其他用户:读旧库 return oldOrderMapper.findById(orderId); }}
// 配置中心动态调整 grayPercentage@NacosValue(value = "${gray.percentage:0}", autoRefreshed = true)private int grayPercentage;Q8:ShardingSphere 生产环境有哪些坑?实战
Section titled “Q8:ShardingSphere 生产环境有哪些坑?”坑一:跨分片 ORDER BY + LIMIT 内存暴涨
-- 问题:跨分片深分页SELECT * FROM orders ORDER BY create_time DESC LIMIT 10 OFFSET 100000;
-- ShardingSphere 处理:-- 1. 向每个分片发送:SELECT ... LIMIT 100010-- 2. 每个分片返回 100010 条-- 3. 64 个分片 × 100010 条 = 640 万条在内存中-- 4. 归并排序后取第 100001~100010 条
-- 内存暴涨,可能 OOM
-- 解决方案:-- 1. 禁止深分页(限制最大页码)-- 2. 游标分页(WHERE id > last_id)-- 3. 按分表键查询(单分片分页)坑二:分布式事务性能差
// 问题:跨库事务用 Seata AT 模式,性能极差@Transactionalpublic void placeOrder(Long userId, Long productId) { // 订单库 orderMapper.insert(...);
// 库存库(不同库) productMapper.decreaseStock(...);
// Seata AT 模式:全局锁 + 回滚日志 → 性能下降 50%-80%}
// 解决方案:// 1. 避免跨库事务(设计时考虑)// 2. 使用消息事务(最终一致性)// 3. TCC 模式(性能好,但业务侵入强)坑三:worker-id 冲突导致 ID 重复
# 问题:多个实例 worker-id 相同key-generators: snowflake: type: SNOWFLAKE props: worker-id: 1 # 硬编码,所有实例都是 1
# 结果:生成重复 ID
# 解决方案:K8s StatefulSetworker-id: ${HOSTNAME##*-} # 从 Pod 名称提取序号# order-service-0 → worker-id=0# order-service-1 → worker-id=1坑四:分片键变更需要全量迁移
-- 问题:初期按 status 分片,后期发现不均匀-- 想改按 user_id 分片
-- 解决方案:-- 1. 只能全量迁移(停服或双写)-- 2. 成本极高
-- 建议:设计时充分调研,选择合适的分片键坑五:COUNT(DISTINCT) 跨分片不准确
-- 问题:统计用户数SELECT COUNT(DISTINCT user_id) FROM orders;
-- ShardingSphere 处理:-- 1. 每个分片返回 COUNT(DISTINCT user_id)-- 2. 归并:直接相加(错误!)
-- 原因:同一 user_id 可能在多个分片
-- 解决方案:-- 1. 应用层归并:先查所有 user_id,再去重SELECT DISTINCT user_id FROM orders; -- 应用层去重
-- 2. 用 ES 预计算坑六:SELECT * 回表过多
-- 问题:SELECT * FROM orders WHERE user_id = 123;
-- 有索引 idx_user_id(user_id)-- 扫描 user_id 索引 → 回表查询完整数据-- 如果 user_id=123 有 10000 条 → 回表 10000 次
-- 解决方案:覆盖索引CREATE INDEX idx_uid_time_cover ON orders(user_id, create_time, order_no, status, amount);
-- 查询只需要的列SELECT id, order_no, status, amount FROM orders WHERE user_id = 123;-- 覆盖索引,无需回表