【架构实战】分库分表ShardingSphere:突破数据库瓶颈
作者:架构实战系列 | 日期:2026-05-25
一个"表太大"的隐藏杀手:单表亿级数据背后的真相
2024年初,笔者接手了一个"历史遗留"的订单系统。团队当时面临一个典型问题:订单表已经超过1.2亿行,单表查询最慢超过5秒,DBA每天手工优化索引、改表结构,但性能还是持续恶化。
团队里有人提议:"换TiDB吧!" 也有人说:"上阿里云PolarDB吧,按量付费!" 讨论了两个月,最终我们选择了分库分表方案,迁移到ShardingSphere-Proxy。3个月后,系统QPS从3000提升到8万,99线延迟从5秒降到80毫秒,而成本只增加了一台服务器。
这次经历让我深刻认识到:分库分表不是万能药,但它是突破单机数据库瓶颈的最成熟、成本最低的方案。本文将结合这次真实迁移案例,从分片策略、读写分离、分布式主键,到完整的配置实战和踩坑记录,逐一讲解。
一、分库分表:为什么需要,以及何时需要
1.1 单机数据库的性能天花板
MySQL的单表性能在数据量超过5000万行时急剧下降,原因有三:
| 瓶颈点 | 说明 |
|---|---|
| B+树深度增加 | 数据量越大,树越高,一次索引查询需要的磁盘IO次数越多 |
| 索引维护成本 | 插入/更新时,页分裂和索引更新开销指数级增长 |
| Buffer Pool污染 | 全量数据塞满内存池,热数据无法充分利用CPU缓存 |
一个实测数据(8核16GB SSD MySQL):
- 单表100万行:平均查询延迟 5ms
- 单表1000万行:平均查询延迟 80ms
- 单表1亿行:平均查询延迟 1500ms+(索引失效时)
1.2 何时应该分库分表?
触发条件(满足任意一条):
- 单表数据量超过5000万行
- 单库QPS超过单机MySQL承载能力(通常2万~5万QPS)
- 存储空间超过单机磁盘容量
- 单表宽度超过20个字段且经常全表扫描
分库分表不是银弹------它引入的复杂度远超单机MySQL:
- 跨分片查询困难(无法使用局部索引)
- 分布式事务成本高
- 跨分片JOIN性能差
- 运维复杂度大幅提升
因此,先考虑优化单机性能(索引、参数调优、读写分离),再考虑分库分表。
二、分片策略:选择正确的"切割方式"
2.1 分片键(Shard Key)选择原则
分片键决定了数据的分布方式,是分库分表的核心。好的分片键应该满足:
- 业务相关性高:大多数查询都带有分片键条件
- 数据分布均匀:避免出现"热点分片"
- 查询可路由:能定位到少数分片,而非全部分片
常见分片键选择:
| 业务场景 | 推荐分片键 | 说明 |
|---|---|---|
| 用户中心 | user_id | 按用户维度查询是主流 |
| 订单系统 | user_id 或 order_id | 按买家查、按订单查两种场景 |
| 商品系统 | category_id | 按类目浏览是主流 |
| 日志系统 | create_time | 按时间范围查询是主流 |
2.2 分片算法详解
2.2.1 取模算法(Mod)
最简单直接的算法:分片 = hash(key) % 分片数
yaml
shardingAlgorithms:
mod_algorithm:
type: MOD
props:
sharding-count: 4 # 4个分片
优点: 数据分布均匀
缺点: 扩容时(从4扩到8)需要全量数据迁移,代价极大
2.2.2 范围算法(Range)
按时间或ID范围切分:分片 = key / 范围区间
yaml
shardingAlgorithms:
range_algorithm:
type: RANGE_BY_MONTH
props:
date-algorithm-column: create_time
date-format: yyyy-MM-dd
适用场景: 日志、流水、监控等按时间查询的业务
优点: 扩容方便,直接增加新范围的分片
缺点: 容易产生热点(如当前月份的分片访问量远超历史月份)
2.2.3 一致性Hash算法(HashMod)
将分片数设为2的幂次,扩容时只需迁移50%的数据:
yaml
shardingAlgorithms:
consistent_hash_algorithm:
type: CONSISTENT_HASH_V1
props:
sharding-count: 4
virtual-nodes: 10 # 每个物理分片10个虚拟节点
核心原理: 将数据按Hash后映射到2^32的环形空间上,每个物理节点负责环上一段区域,扩容时只影响相邻区域的数据。
2.2.4 复合分片算法(Complex)
多个分片键组合路由:
yaml
shardingAlgorithms:
complex_algorithm:
type: COMPLEX_INLINE
props:
sharding-expression: "ds_${user_id % 2}.t_order_${order_id % 2}"
2.3 分片策略选择决策树
是否有明确的高频查询分片键?
├── 是 → 优先选择该键作为分片键
│ ↓
│ 数据分布均匀吗?
│ ├── 是 → 取模/一致性Hash
│ └── 否 → 范围 + 热点隔离
│
└── 否 → 考虑以下方案:
├── 时间分片(最通用)
├── 默认路由(全部分片扫描,但限制结果集)
└── 考虑是否真的需要分库分表
三、分布式主键:解决自增ID的跨分片问题
3.1 为什么不能使用自增主键?
分库分表后,每个分片维护自己的自增主键,会出现:
- 订单表分4片后,4个分片都从1开始自增 → 订单ID重复
- 无法用单个ID作为全局唯一标识
- 跨分片查询时ID无序,无法利用范围查询优化
3.2 雪花算法(Snowflake)
Snowflake是Twitter开源的分布式ID算法,64位Long型整数,结构如下:
1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号
= 64位
0 | 0000000000 0000000000 0000000000 0000000000 0 | 0000000000 | 0000000000
[符号位] [ 41位时间戳(毫秒) ] [10位机器ID] [12位序列号]
- 时间戳部分:可支持69年(2^41 / 1000 / 3600 / 24 / 365 ≈ 69年)
- 机器ID:10位,最多支持1024个节点
- 序列号:每节点每毫秒最多生成4096个ID
3.3 ShardingSphere集成雪花算法
yaml
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..1}
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
# 或者使用雪花算法的变种配置
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
# 工作机器ID(建议用服务器IP最后10位取模)
worker-id: 1
# 最大时钟回拨毫秒数(允许时钟漂移)
max-vibration-offset: 1
# 启用自定义时间戳(用于调试)
# epoch: 1609459200000 # 2021-01-01
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: t_order_inline
3.4 雪花算法的生产坑点
坑点1:时钟回拨
服务器时钟有时会因为NTP同步而回拨,导致生成重复ID。
解决方案:
java
@Component
public class ClockBackSnowflakeIdGenerator implements ShardingKeyGenerator {
private final SnowflakeIdWorker worker;
public ClockBackSnowflakeIdGenerator() {
// 使用配置的工作节点ID
this.worker = new SnowflakeIdWorker(1, 1);
}
@Override
public Comparable<?> generateKey() {
long id = worker.nextId();
// 如果发生时钟回拨,这里会抛出异常
// 生产中需要兜底:等待时钟追上
return id;
}
/**
* 带时钟保护的自旋等待
*/
private static class SnowflakeIdWorker {
private final long twepoch = 1609459200000L;
private final long workerIdBits = 10L;
private final long sequenceBits = 12L;
private final long workerIdShift;
private final long timestampLeftShift;
private final long sequenceMask;
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdWorker(long workerId, long datacenterId) {
this.workerId = workerId;
this.workerIdShift = sequenceBits;
this.timestampLeftShift = sequenceBits + workerIdBits;
this.sequenceMask = ~(-1L << sequenceBits);
}
public synchronized long nextId() {
long timestamp = timeGen();
// 时钟回拨处理
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 允许5ms以内的时钟漂移,等待追上
timestamp = lastTimestamp;
} else {
throw new RuntimeException("Clock moved backwards");
}
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift)
| (workerId << workerIdShift)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
}
}
坑点2:WorkerID分配问题
1024个节点上限在超大规模集群中不够用,且WorkerID需要人工分配,容易出错。
解决方案: 使用ZK/Etcd协调分配,或使用百度UidGenerator、Leaf等成熟方案。
四、读写分离:分库分表的好搭档
4.1 分库分表 + 读写分离的组合架构
[应用层]
|
[ShardingSphere-Proxy]
(分片路由 + 读写分离)
|
+------------+------------+
| |
[写库集群] [读库集群]
ds_0(primary) ds_0_replica
ds_1(primary) + ds_1_replica
... ...
4.2 ShardingSphere读写分离完整配置
yaml
# conf/config-readwrite-splitting.yaml
schemaName: readwrite_splitting_db
dataSources:
# ===== 写库 =====
ds_primary_0:
url: jdbc:mysql://192.168.1.101:3306/app_ds_0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: sharding_proxy
password: ShardingProxy@2025
connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
maxPoolSize: 50
minPoolSize: 10
ds_primary_1:
url: jdbc:mysql://192.168.1.102:3306/app_ds_1?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: sharding_proxy
password: ShardingProxy@2025
connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
maxPoolSize: 50
minPoolSize: 10
# ===== 读库 =====
ds_replica_0_1:
url: jdbc:mysql://192.168.1.103:3306/app_ds_0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: sharding_proxy
password: ShardingProxy@2025
connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
maxPoolSize: 50
minPoolSize: 5
ds_replica_0_2:
url: jdbc:mysql://192.168.1.104:3306/app_ds_0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: sharding_proxy
password: ShardingProxy@2025
connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
maxPoolSize: 50
minPoolSize: 5
ds_replica_1_1:
url: jdbc:mysql://192.168.1.105:3306/app_ds_1?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: sharding_proxy
password: ShardingProxy@2025
connectionPoolClassName: com.zaxxer.hikari.HikariDataSource
maxPoolSize: 50
minPoolSize: 5
rules:
- !READWRITE_SPLITTING
# 每个分片库配置一主多从
dataSources:
# ds_0的读写分离组
ds_0_replica:
writeDataSourceName: ds_primary_0
readDataSourceNames:
- ds_replica_0_1
- ds_replica_0_2
loadBalancerName: random_weight
# ds_1的读写分离组
ds_1_replica:
writeDataSourceName: ds_primary_1
readDataSourceNames:
- ds_replica_1_1
loadBalancerName: random_weight
loadBalancers:
# 随机加权策略(根据配置权重随机选)
random_weight:
type: RANDOM
# 轮询策略
round_robin:
type: ROUND_ROBIN
# 固定副本策略(每次查询同一从库)
fixed_replica:
type: FIXED_REPLICA
props:
replica-id: ds_replica_0_1
五、完整实战案例:电商订单系统分库分表迁移
5.1 项目背景与迁移前状态
| 指标 | 迁移前 | 迁移后目标 |
|---|---|---|
| 订单表数据量 | 1.2亿行 | 按月分片,历史归档 |
| 峰值QPS | 3,200 | 80,000+ |
| P99延迟 | 5,000ms | <100ms |
| 可用性 | 单机无高可用 | 主从自动切换 |
| 月均故障时长 | ~4小时 | <15分钟 |
5.2 迁移方案设计
分片策略选择
最终选择按订单ID取模分片 + 时间范围归档的混合策略:
- 活跃订单 (近3个月):按
order_id % 8分4库×2表 = 8个分片 - 历史订单 (3个月前):按
create_time月份分表,归档到冷数据集群 - 分片键 :优先
order_id(覆盖订单号查询),次选user_id(覆盖用户订单列表)
yaml
rules:
- !SHARDING
defaultDatabaseStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: database_inline
tables:
t_order:
actualDataNodes: ds_${0..3}.t_order_${0..1}
databaseStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: database_inline
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: table_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
# 绑定表:订单和订单明细同分片,避免跨分片JOIN
bindingTables:
- t_order,t_order_item
# 广播表:所有分片都保留一份(字典表等)
broadcastTables:
- t_dict_product_category
- t_dict_region
shardingAlgorithms:
database_inline:
type: INLINE
props:
algorithm-expression: ds_${order_id % 4}
table_inline:
type: INLINE
props:
algorithm-expression: t_order_${order_id % 2}
# 历史订单按月分表
t_order_history:
type: CLASS_BASED
props:
strategy: STANDARD
algorithmClassName: com.example.sharding.MonthShardingAlgorithm
5.3 历史数据迁移脚本
python
#!/usr/bin/env python3
"""
历史订单迁移脚本
功能:将超过3个月的订单迁移到历史表(按月分表)
"""
import pymysql
import datetime
import time
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
# 迁移配置
BATCH_SIZE = 5000 # 每批迁移数量
CUTOFF_DATE = datetime.datetime.now() - datetime.timedelta(days=90)
MAX_WORKERS = 4 # 并行迁移线程数
CHUNK_SIZE = 10000 # 查询时的分块大小
# 源数据库连接(从库,保护主库)
SRC_DB_CONFIG = {
'host': '192.168.1.102',
'port': 3306,
'user': 'migration_user',
'password': 'Migration@Pass',
'database': 'app_order',
'charset': 'utf8mb4'
}
# 目标分片库配置
TARGET_DB_CONFIGS = [
{'host': '192.168.1.101', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_0'},
{'host': '192.168.1.101', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_1'},
{'host': '192.168.1.102', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_2'},
{'host': '192.168.1.102', 'port': 3306, 'user': 'migration_user', 'password': 'Migration@Pass', 'database': 'app_order_ds_3'},
]
def get_target_db(order_id):
"""根据订单ID计算目标分片"""
shard_index = order_id % 4
return TARGET_DB_CONFIGS[shard_index]
def migrate_batch(batch_records):
"""迁移一批订单"""
success_count = 0
error_count = 0
for record in batch_records:
order_id = record['order_id']
target_config = get_target_db(order_id)
try:
conn = pymysql.connect(**target_config)
cursor = conn.cursor()
# 插入订单数据
cursor.execute("""
INSERT IGNORE INTO t_order (order_id, user_id, product_id, amount, status, create_time)
VALUES (%(order_id)s, %(user_id)s, %(product_id)s, %(amount)s, %(status)s, %(create_time)s)
""", record)
conn.commit()
cursor.close()
conn.close()
success_count += 1
except Exception as e:
error_count += 1
logger.error(f"迁移订单{order_id}失败: {e}")
return success_count, error_count
def migration_worker(start_id, end_id, batch_size=BATCH_SIZE):
"""单线程迁移worker"""
src_conn = pymysql.connect(**SRC_DB_CONFIG)
total_success = 0
total_error = 0
while start_id < end_id:
with src_conn.cursor(pymysql.cursors.DictCursor) as cursor:
cursor.execute(f"""
SELECT order_id, user_id, product_id, amount, status, create_time
FROM t_order
WHERE order_id >= {start_id} AND order_id < {end_id}
AND create_time < '{CUTOFF_DATE.strftime('%Y-%m-%d %H:%M:%S')}'
ORDER BY order_id
LIMIT {batch_size}
""", )
records = cursor.fetchall()
if not records:
break
success, error = migrate_batch(records)
total_success += success
total_error += error
# 进度汇报
last_id = records[-1]['order_id']
logger.info(f"进度: 已迁移 {total_success} 条, 失败 {total_error} 条, 当前ID={last_id}")
start_id = records[-1]['order_id'] + 1
time.sleep(0.1) # 控制迁移速率,保护源库
src_conn.close()
return total_success, total_error
if __name__ == '__main__':
logger.info("=" * 50)
logger.info("历史订单迁移任务开始")
logger.info(f"截止日期: {CUTOFF_DATE}")
# 获取迁移范围
src_conn = pymysql.connect(**SRC_DB_CONFIG)
with src_conn.cursor() as cursor:
cursor.execute("SELECT MIN(order_id) as min_id, MAX(order_id) as max_id FROM t_order WHERE create_time < %s", (CUTOFF_DATE,))
result = cursor.fetchone()
min_id, max_id = result[0], result[1]
logger.info(f"待迁移ID范围: {min_id} ~ {max_id}")
src_conn.close()
# 并行迁移
id_ranges = [(i, i + (max_id - min_id) // MAX_WORKERS + 1) for i in range(min_id, max_id + 1, (max_id - min_id) // MAX_WORKERS + 1)]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = [executor.submit(migration_worker, start, end) for start, end in id_ranges]
total_success = 0
total_error = 0
for future in as_completed(futures):
s, e = future.result()
total_success += s
total_error += e
logger.info("=" * 50)
logger.info(f"迁移完成!成功: {total_success}, 失败: {total_error}")
5.4 应用层改造代码
java
/**
* 分片订单服务
* 核心:使用ShardingSphere提供的Hint API强制路由
*/
@Service
@Slf4j
public class ShardingOrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 按订单ID查询(自动路由到正确分片)
*/
public Order queryByOrderId(Long orderId) {
// ShardingSphere根据order_id自动计算分片
// 应用层无需关心分片路由细节
return orderMapper.selectByOrderId(orderId);
}
/**
* 按用户ID查询订单(强制路由到包含该用户的分片)
* 使用Hint API强制路由
*/
public List<Order> queryByUserId(Long userId, Integer page, Integer size) {
// Hint强制路由:根据user_id计算分片
HintManager hintManager = HintManager.getInstance();
// 强制使用数据库分片键:user_id % 4
int dbIndex = (int) (userId % 4);
hintManager.addDatabaseShardingValue("t_order", "user_id", userId);
hintManager.addTableShardingValue("t_order", "user_id", userId);
try {
// 查询当前用户的所有分片表,合并结果
List<Order> allOrders = new ArrayList<>();
for (int tableIndex = 0; tableIndex < 2; tableIndex++) {
// Hint指定分片键值
String sql = String.format(
"SELECT * FROM t_order_%d WHERE user_id = ? ORDER BY create_time DESC LIMIT %d OFFSET %d",
tableIndex, size, (page - 1) * size
);
List<Order> orders = jdbcTemplate.query(sql,
(rs, rowNum) -> mapRow(rs), userId);
allOrders.addAll(orders);
}
// 内存中排序分页
allOrders.sort((a, b) -> b.getCreateTime().compareTo(a.getCreateTime()));
return allOrders.stream()
.skip((long) (page - 1) * size)
.limit(size)
.collect(Collectors.toList());
} finally {
HintManager.close();
}
}
/**
* 创建订单(雪花算法生成全局唯一ID)
*/
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
// 雪花算法生成订单ID(由ShardingSphere自动生成或应用层生成)
Long orderId = IdGeneratorHolder.getGenerator("snowflake").generateKey().longValue();
order.setOrderId(orderId);
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.PENDING_PAYMENT);
order.setCreateTime(LocalDateTime.now());
// 插入(ShardingSphere根据order_id自动路由到正确分片)
orderMapper.insert(order);
log.info("订单创建成功, orderId={}, userId={}", orderId, request.getUserId());
return order;
}
/**
* 批量查询订单(跨分片聚合)
* 注意:会扫描所有分片,性能较差,生产环境慎用
*/
public PageResult<Order> queryByOrderIds(List<Long> orderIds) {
if (orderIds == null || orderIds.isEmpty()) {
return PageResult.empty();
}
// Hint关闭分片规则,全分片扫描
HintManager hintManager = HintManager.getInstance();
hintManager.setMasterRouteOnly(); // 可选:强制走主库
try {
String sql = "SELECT * FROM t_order WHERE order_id IN ("
+ orderIds.stream().map(String::valueOf).collect(Collectors.joining(","))
+ ")";
List<Order> orders = jdbcTemplate.query(sql,
(rs, rowNum) -> mapRow(rs));
// 按传入顺序排序
Map<Long, Order> orderMap = orders.stream()
.collect(Collectors.toMap(Order::getOrderId, o -> o));
List<Order> sorted = orderIds.stream()
.map(orderMap::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new PageResult<>(sorted, (long) sorted.size());
} finally {
HintManager.close();
}
}
private Order mapRow(ResultSet rs) throws SQLException {
Order order = new Order();
order.setOrderId(rs.getLong("order_id"));
order.setUserId(rs.getLong("user_id"));
order.setProductId(rs.getLong("product_id"));
order.setAmount(rs.getBigDecimal("amount"));
order.setStatus(OrderStatus.valueOf(rs.getString("status")));
order.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime());
return order;
}
}
六、踩坑实录:生产迁移的血泪经验
踩坑1:分片键选择错误导致全分片扫描
现象: 用户反馈"按订单号查询"的响应时间反而比迁移前更慢了,部分查询从5ms变成300ms。
原因: 初始设计时按 user_id 分片,但订单查询更多是用订单号直接查(通过索引)。改用 user_id 分片后,订单号查询需要扫描全部8个分片。
排查方法:
sql
-- ShardingSphere开启SQL日志,查看实际路由
-- conf/server.yaml 中设置:
props:
sql-show: true
max-queue-size: 8192
-- 查看日志中的 actual-sql:
-- SELECT * FROM t_order_0 WHERE order_id=? -- 单分片命中
-- SELECT * FROM t_order_0 WHERE order_id=? UNION ALL SELECT * FROM t_order_1 ... -- 全分片扫描
解决方案:
- 短期:将订单号查询改为"订单号 → 查缓存 → 不存在再全分片"的兜底逻辑
- 长期 :改用
order_id作为分片键,订单号查询改为直接路由到单分片
踩坑2:分片扩容时数据迁移引发的服务中断
现象: 从4分片扩容到8分片时,order_id % 4 变成 order_id % 8,历史数据全部路由错误,用户查看历史订单出现数据丢失。
原因: 取模算法的分母变化后,Hash结果完全不同。4分片下的 order_id=5 存在 ds_1,但扩容后 order_id=5 应该在 ds_5。
解决方案:
yaml
# 一致性Hash扩容:分片数必须是2的幂次
# 4分片:ds_${order_id % 4}
# 扩容到8分片:ds_${order_id % 8}
# 扩容到16分片:ds_${order_id % 16}
# 每次扩容只迁移50%的数据
shardingAlgorithms:
database_inline:
type: INLINE
props:
# 使用2的幂次分片,支持平滑扩容
algorithm-expression: ds_${order_id % Long.compare(order_id, 0) > 0 ? (order_id % 8) : 0}
最佳实践:
- 初期多分配分片(留余量),避免频繁扩容
- 使用一致性Hash算法
- 扩容期间采用双写策略:新分片写入,旧分片只读,逐步切流
踩坑3:跨分片JOIN导致内存溢出
现象: 一个复杂的报表查询(订单表JOIN用户表JOIN商品表)在8分片环境下执行时,应用服务器OOM崩溃。
原因: 每个分片返回大量数据,8个分片合并后超出应用服务器内存。
解决方案:
java
// 1. 禁止跨分片JOIN,改为应用层合并
public List<OrderVO> getOrderReport(Long userId, LocalDateTime startDate, LocalDateTime endDate) {
// Step1: 从各分片获取订单
List<Order> orders = getOrdersFromAllShards(userId, startDate, endDate);
// Step2: 批量获取关联的用户信息和商品信息
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
Set<Long> productIds = orders.stream().map(Order::getProductId).collect(Collectors.toSet());
// 这些关联数据通过Redis缓存,不走分片查询
Map<Long, User> userMap = getUsersFromCache(userIds);
Map<Long, Product> productMap = getProductsFromCache(productIds);
// Step3: 应用层组装VO
return orders.stream().map(order -> OrderVO.builder()
.orderId(order.getOrderId())
.userName(userMap.get(order.getUserId()).getName())
.productName(productMap.get(order.getProductId()).getName())
.build()
).collect(Collectors.toList());
}
// 2. 开启流式查询,避免全量加载到内存
jdbcTemplate.setFetchSize(Integer.MIN_VALUE); // MySQL流式查询标志
踩坑4:分布式事务导致下单失败率飙升
现象: 迁移到ShardingSphere后,下单接口的超时错误率从0.1%飙升到5%。
原因: 订单表分片后,扣减库存需要跨库操作,引入分布式事务。但ShardingSphere的默认XA事务性能极差(TPS下降70%),且网络抖动时频繁超时。
解决方案:
java
// 方案1:使用柔性事务(Seata AT模式)替代XA
// 适合:一致性要求不是100%,可接受最终一致性的场景
@Configuration
public class SeataConfig {
@Bean
public GlobalTransactionScanner globalTransactionScanner() {
return new GlobalTransactionScanner("order-service", "order_tx_group");
}
}
@Service
public class OrderService {
@GlobalTransactional(timeoutMills = 30000, name = "create-order")
public void createOrder(CreateOrderRequest request) {
// 1. 扣减库存(远程调用库存服务)
inventoryService.deductStock(request.getProductId(), request.getQuantity());
// 2. 创建订单(本地事务)
orderMapper.insert(order);
// Seata自动管理回滚:任何一个失败,全部回滚
}
}
// 方案2:业务层消息队列最终一致性(推荐,最稳妥)
@Service
public class OrderService {
@Transactional
public void createOrder(CreateOrderRequest request) {
// 1. 本地创建订单(状态=待确认)
Order order = createPendingOrder(request);
// 2. 发送扣减库存消息到MQ
mqProducer.send("inventory:deduct", new DeductStockMessage(
request.getProductId(), request.getQuantity(), order.getOrderId()
));
// 3. 订单状态通过消费MQ消息更新(保证最终一致)
}
@KafkaListener(topics = "inventory:deduct:result")
public void handleInventoryResult(InventoryResultMessage message) {
if (message.isSuccess()) {
orderMapper.updateStatus(message.getOrderId(), OrderStatus.PAID);
} else {
orderMapper.updateStatus(message.getOrderId(), OrderStatus.FAILED);
// 触发补偿逻辑
}
}
}
踩坑5:分页查询结果不准
现象: 订单列表第1页显示20条,第2页开始有大量重复数据,第10页开始数据减少并最终消失。
原因: 分页 LIMIT 20 OFFSET 20 在8个分片上各执行,汇总后第2页实际拿到的是各分片的第2-3条数据,并非全局的第21-40条。
正确实现:
java
/**
* 分片环境下正确的分页查询
* 使用游标分页(KeySet Pagination)替代偏移量分页
*/
public PageResult<Order> queryOrdersWithCursor(Long userId, Long lastOrderId, int size) {
HintManager hintManager = HintManager.getInstance();
try {
List<Order> orders = new ArrayList<>();
// 先用游标查下一页
String cursorSql = String.format("""
SELECT * FROM t_order WHERE user_id = ? AND order_id > ?
ORDER BY order_id LIMIT %d
""", size);
List<Order> nextPage = jdbcTemplate.query(cursorSql,
(rs, rowNum) -> mapRow(rs), userId, lastOrderId);
if (!nextPage.isEmpty()) {
return new PageResult<>(nextPage, nextPage.size() == size ? nextPage.get(size-1).getOrderId() : null);
}
// 如果游标查不到,fallback到第一页
String firstPageSql = String.format(
"SELECT * FROM t_order WHERE user_id = ? ORDER BY order_id LIMIT %d", size);
List<Order> firstPage = jdbcTemplate.query(firstPageSql,
(rs, rowNum) -> mapRow(rs), userId);
return new PageResult<>(firstPage, firstPage.size() == size ? firstPage.get(size-1).getOrderId() : null);
} finally {
HintManager.close();
}
}
七、总结与思考
核心要点回顾
- 分片键是分库分表的核心:选择错误会导致全分片扫描,性能反而下降。选择高频查询字段 + 数据分布均匀是黄金准则。
- 分片算法决定扩容成本:取模简单但扩容代价大,一致性Hash支持平滑扩容但实现复杂。
- 雪花算法解决分布式ID问题:但要注意时钟回拨和WorkerID分配问题。
- 分库分表+读写分离是黄金组合:写压力分摊到主库集群,读压力分摊到从库集群。
- 跨分片JOIN是性能杀手:通过应用层组装、冷热数据分离、广播表等手段规避。
- 分布式事务是最大挑战:强一致性用Seata/XA,最终一致性用MQ消息队列。
思考题
- 如果订单表按user_id分片,用户查看自己所有订单时需要查询多少个分片?如果按create_time月份分片呢?哪种更好?
- 分库分表后,MySQL的局部索引是否还有意义?全局索引如何实现(Proxy层的索引表 or 引入ES)?
- 假设业务增长10倍,需要从4分片扩到16分片,如何设计平滑扩容方案使得业务中断时间为0?
- Seata的AT模式在分支事务失败时的回滚逻辑是什么?什么情况下会出现脏写?
个人观点
分库分表是"架构进化"的里程碑,但不是终点。它解决了单机数据库的性能瓶颈,但引入了运维复杂度、数据迁移、分布式事务等一系列新问题。我的经验是:在决定分库分表之前,至少尝试以下优化手段:
- 索引优化(覆盖索引、联合索引、最左前缀)
- Query Cache / Row Cache(MySQL 8.0已废弃,需用Redis替代)
- 读写分离(至少能提升2-3倍读性能)
- 数据归档(冷热分离,把3个月前的数据迁到归档库)
- 参数调优(Buffer Pool、IO参数、连接池)
当以上手段都尝试过、系统瓶颈仍然存在时,再选择分库分表。记住:过早优化是万恶之源,过晚优化是灾难之源。
📌 架构实战系列完结!感谢阅读。
系列回顾:
- 第一篇:《Redis集群与缓存策略》
- 第二篇:《MySQL主从复制与读写分离》
- 第三篇:《分库分表ShardingSphere》