Skip to content

ShardingSphere 深度解析

面试官:你用过 ShardingSphere 吗?它是怎么工作的?

:用过,ShardingSphere 是一个分布式数据库中间件,通过 SQL 解析 → 路由 → 改写 → 执行 → 归并的流程,对应用透明地实现分库分表、读写分离。

面试官:JDBC 模式和 Proxy 模式有什么区别?你们用的哪种?

这个追问考察你对架构选型的理解。能说清「两种模式的优缺点和适用场景」的候选人,才是真正用过。


链式追问一:ShardingSphere 架构模式

Section titled “链式追问一:ShardingSphere 架构模式”

Q1:ShardingSphere 的三种接入模式有什么区别?如何选择?高频

Section titled “Q1:ShardingSphere 的三种接入模式有什么区别?如何选择?”

三种接入模式

模式架构性能语言支持运维复杂度适用场景
ShardingSphere-JDBCJava 库,应用内嵌,替换 DataSource最高(进程内调用)仅 JavaJava 应用,性能敏感
ShardingSphere-Proxy独立部署的数据库代理,伪装成 MySQL较低(额外网络跳转)语言无关多语言环境,DBA 管理
ShardingSphere-SidecarK8s 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各分片返回分页结果,全局分页

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雪花算法趋势递增;高性能时钟回拨风险
UUIDUUID全局唯一无序;太长
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_no
public 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_id
public 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 o
JOIN order_items oi ON o.id = oi.order_id
WHERE 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_name
FROM orders o
JOIN regions r ON o.region_id = r.id
WHERE o.user_id = 123;
-- regions 表在所有分片都有完整数据
-- → JOIN 在单分片内执行

方案五:ES 辅助查询

// 场景:复杂搜索(按商品名、价格范围、地区等多条件)
// 方案:将订单数据同步到 ES,用 ES 做搜索
// 1. 写入时双写
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // 写 MySQL
// 异步写 ES
CompletableFuture.runAsync(() -> {
esClient.index("orders", order);
});
}
// 2. 搜索时用 ES
public 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 使用示例

强制读主库

// 方式一:HintManager
public 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 users
WHERE 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") // 或使用 HintManager
public User getCurrentUser(Long userId) {
return userMapper.findById(userId);
}

Q7:如何不停机迁移到 ShardingSphere 分库分表?实战

Section titled “Q7:如何不停机迁移到 ShardingSphere 分库分表?”

经典方案:双写 + 数据同步 + 灰度切流

阶段一:双写(新旧库同时写)
应用 → 写旧库(单库单表)
→ 写新库(分库分表,异步,允许失败)
阶段二:历史数据同步
旧库 → 数据迁移工具(DataX/Canal/ShardingSphere-Scaling)→ 新库
按时间分批迁移(如按月份)
阶段三:数据校验
对比新旧库数据(行数、抽样对比、checksum)
修复不一致数据
阶段四:切读(灰度)
1% 流量读新库 → 观察 → 10% → 50% → 100%
阶段五:停双写,下线旧库
只写新库 → 旧库归档

双写代码示例

@Service
public 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 模式,性能极差
@Transactional
public 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 StatefulSet
worker-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;
-- 覆盖索引,无需回表