2758 字
14 分钟
高并发场景下的数据库扩展策略

高并发场景下的数据库扩展策略#

一、为什么需要分库分表#

当单库单表面临以下问题时,就需要考虑分库分表:

性能瓶颈#

  • 存储瓶颈:单表数据量超过千万级,索引效率下降
  • 并发瓶颈:数据库连接数、IOPS、网络带宽达到上限
  • 查询瓶颈:即使有索引,大表查询仍然缓慢

典型阈值参考#

  • 单表数据量:500 万-1000 万条
  • 单表存储:20GB-50GB
  • QPS:单库 5000-10000

二、分库分表策略#

1. 垂直拆分#

垂直分库:按业务模块拆分

原单库:
- 用户库:用户表、登录表、权限表...
拆分后:
- 用户中心库:用户表、登录表
- 订单库:订单表、订单明细表
- 商品库:商品表、库存表
- 支付库:支付记录表

垂直分表:按字段拆分

-- 原用户表(字段过多)
CREATE TABLE user (
user_id BIGINT,
username VARCHAR(50),
password VARCHAR(100),
-- ... 20个基础字段
avatar TEXT, -- 大字段
description TEXT, -- 大字段
login_history JSON -- 大字段
);
-- 拆分后
-- 基础表(高频访问)
CREATE TABLE user_base (
user_id BIGINT PRIMARY KEY,
username VARCHAR(50),
password VARCHAR(100),
-- 常用字段
);
-- 扩展表(低频访问)
CREATE TABLE user_ext (
user_id BIGINT PRIMARY KEY,
avatar TEXT,
description TEXT,
login_history JSON
);

2. 水平拆分(重点)#

水平分库:将同一张表的数据分散到多个数据库 水平分表:将同一张表的数据分散到多张表

三、分片策略详解#

1. 哈希分片(最常用)#

适用场景:数据分布均匀,按 ID 查询为主

// 按用户ID哈希分片
public class HashShardingStrategy {
private static final int DB_COUNT = 4; // 4个库
private static final int TABLE_COUNT = 8; // 每库8张表
// 计算库编号
public int getDbIndex(Long userId) {
return Math.abs(userId.hashCode()) % DB_COUNT;
}
// 计算表编号
public int getTableIndex(Long userId) {
return Math.abs(userId.hashCode()) % (DB_COUNT * TABLE_COUNT)
/ DB_COUNT;
}
// 获取完整表名
public String getTableName(Long userId) {
int dbIndex = getDbIndex(userId);
int tableIndex = getTableIndex(userId);
return String.format("db_%d.order_%d", dbIndex, tableIndex);
}
}
// 使用示例
Long userId = 123456789L;
String tableName = strategy.getTableName(userId);
// 结果:db_1.order_3

电商订单表示例

原表:order(10亿条数据)
分库分表后:
db_0: order_0, order_1, order_2, order_3
db_1: order_0, order_1, order_2, order_3
db_2: order_0, order_1, order_2, order_3
db_3: order_0, order_1, order_2, order_3
每个表约3000万条数据

2. 范围分片#

适用场景:时间序列数据、有明显冷热分离

// 按时间范围分片(按月)
public class RangeShardingStrategy {
public String getTableName(Date orderTime) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");
String month = sdf.format(orderTime);
return "order_" + month;
}
}
// 表结构
order_202401 // 2024年1月订单
order_202402 // 2024年2月订单
order_202403 // 2024年3月订单
...

优点

  • 查询时间范围数据效率高
  • 便于归档历史数据
  • 扩容简单(新增月份表)

缺点

  • 数据分布可能不均匀(促销月订单激增)
  • 热点问题(当月表压力大)

3. 地理位置分片#

// 按地区分片
public class GeoShardingStrategy {
public String getDbName(String province) {
// 华东、华南、华北、西南...
String region = getRegion(province);
return "db_" + region;
}
}
// 适用于:
// - 同城配送订单
// - 区域性社交平台
// - O2O业务

4. 一致性哈希#

// 一致性哈希(便于扩容)
public class ConsistentHashStrategy {
private TreeMap<Long, String> nodes = new TreeMap<>();
public ConsistentHashStrategy() {
// 添加虚拟节点
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 150; j++) {
long hash = hash("db_" + i + "_vnode_" + j);
nodes.put(hash, "db_" + i);
}
}
}
public String getDbName(Long userId) {
long hash = hash(userId.toString());
Map.Entry<Long, String> entry = nodes.ceilingEntry(hash);
return entry != null ? entry.getValue() : nodes.firstEntry().getValue();
}
}

四、分片键选择原则#

关键要素#

考虑因素说明示例
查询频率最高频的查询条件用户查订单 →user_id
数据分布确保数据均匀分布✅user_id ❌ 性别
业务关联相关数据在同一分片订单+订单明细
扩展性便于未来扩容哈希 > 范围

电商场景示例#

订单表分片键选择

// ❌ 不好的选择
// 1. 按订单状态分片 - 数据分布极不均匀
// 2. 按商品ID分片 - 查询"我的订单"需要扫描所有分片
// ✅ 好的选择
// 按用户ID分片
CREATE TABLE order_0 (
order_id BIGINT,
user_id BIGINT, -- 分片键
product_id BIGINT,
order_time DATETIME,
INDEX idx_user_id (user_id)
);
// 查询某用户订单:只查一个分片
SELECT * FROM order_X WHERE user_id = 123456;
// 查询某订单详情:订单ID中包含分片信息
// order_id = timestamp + user_id_hash + sequence

社交平台消息表

// 按会话ID分片(保证对话消息在同一分片)
public String getShardKey(Long senderId, Long receiverId) {
// 保证A→B和B→A的消息在同一分片
Long small = Math.min(senderId, receiverId);
Long large = Math.max(senderId, receiverId);
return small + "_" + large;
}

五、常见问题与解决方案#

1. 跨分片查询#

问题:需要查询多个分片的数据

场景示例

-- 查询最近7天所有订单(按user_id分片)
SELECT * FROM order
WHERE order_time > '2024-10-12'
ORDER BY order_time DESC
LIMIT 100;

解决方案

方案 A:分散查询+结果合并

public List<Order> queryRecentOrders(Date startTime, int limit) {
List<Future<List<Order>>> futures = new ArrayList<>();
// 并行查询所有分片
for (int i = 0; i < SHARD_COUNT; i++) {
final int shardIndex = i;
Future<List<Order>> future = executor.submit(() -> {
String sql = String.format(
"SELECT * FROM order_%d WHERE order_time > ? LIMIT %d",
shardIndex, limit
);
return jdbcTemplate.query(sql, startTime);
});
futures.add(future);
}
// 收集结果
List<Order> allOrders = new ArrayList<>();
for (Future<List<Order>> future : futures) {
allOrders.addAll(future.get());
}
// 内存排序+分页
return allOrders.stream()
.sorted(Comparator.comparing(Order::getOrderTime).reversed())
.limit(limit)
.collect(Collectors.toList());
}

方案 B:冗余设计(推荐)

// 订单表:按user_id分片
order_0, order_1, order_2, order_3
// 订单时间索引表:按时间分片
order_time_index_202410 {
order_id,
user_id,
order_time
}
// 查询流程
1. 查时间索引表 → 获取order_id列表
2. 解析order_id → 定位具体分片
3. 批量查询订单详情

方案 C:搜索引擎

// 将订单数据同步到Elasticsearch
// 复杂查询走ES,简单查询走数据库
// Canal监听MySQL binlog → 实时同步到ES

2. 分布式事务#

问题:一个业务操作涉及多个分片

场景示例:转账

// 用户A(在db_0)转账给用户B(在db_1)
// 需要保证:A扣款、B加款 同时成功或失败

解决方案

方案 A:避免分布式事务(最佳实践)

// 设计时避免跨分片操作
// 例如:电商订单
// - 订单表:按user_id分片
// - 订单明细表:按user_id分片(而非product_id)
// 这样创建订单时,主表和明细表在同一分片

方案 B:最终一致性(TCC)

// 转账示例 - TCC模式
public class TransferService {
// Try:预留资源
@Transactional
public void tryTransfer(Long fromUser, Long toUser, BigDecimal amount) {
// 冻结fromUser的金额
accountDao.freeze(fromUser, amount);
// 记录待入账
accountDao.recordPending(toUser, amount);
}
// Confirm:确认提交
@Transactional
public void confirmTransfer(String txId) {
// 扣除冻结金额
accountDao.deduct(txId);
// 实际到账
accountDao.credit(txId);
}
// Cancel:回滚
@Transactional
public void cancelTransfer(String txId) {
// 解冻金额
accountDao.unfreeze(txId);
// 取消待入账
accountDao.cancelPending(txId);
}
}

方案 C:Seata 分布式事务框架

@GlobalTransactional
public void createOrder(Order order) {
// 1. 创建订单(db_0)
orderService.create(order);
// 2. 扣减库存(db_1)
inventoryService.deduct(order.getProductId(), order.getQuantity());
// 3. 扣减积分(db_2)
pointService.deduct(order.getUserId(), order.getPoints());
// Seata保证全部成功或全部回滚
}

方案 D:本地消息表

// 步骤1:本地事务 + 消息表
@Transactional
public void deductBalance(Long userId, BigDecimal amount) {
// 扣款
accountDao.deduct(userId, amount);
// 写入消息表
messageDao.insert(new Message(
"ADD_BALANCE",
toUserId,
amount
));
}
// 步骤2:定时任务扫描消息表 → 发送MQ
// 步骤3:消费者处理加款
// 步骤4:更新消息状态为已完成

3. 全局唯一 ID 生成#

问题:分布式环境下如何生成唯一 ID

方案对比

方案优点缺点适用场景
Snowflake性能高、趋势递增依赖时钟大部分场景
数据库号段简单可靠需要访问 DB并发不高
Redis 自增性能好单点问题配合主从
UUID简单无依赖无序、存储大不推荐

Snowflake 实现

public class SnowflakeIdGenerator {
// 64位Long
// 1位符号位 | 41位时间戳 | 10位机器ID | 12位序列号
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards!");
}
if (timestamp == lastTimestamp) {
// 同一毫秒内,序列号递增
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) {
// 序列号溢出,等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << 22)
| (workerId << 12)
| sequence;
}
}

4. 数据迁移与扩容#

平滑扩容方案

// 从2个库扩容到4个库
// 原分片策略:userId % 2
// 新分片策略:userId % 4
// 步骤1:双写(新老库同时写入)
public void saveOrder(Order order) {
// 写入老分片
int oldShard = order.getUserId() % 2;
oldDataSource[oldShard].save(order);
// 同时写入新分片
int newShard = order.getUserId() % 4;
newDataSource[newShard].save(order);
}
// 步骤2:数据迁移(异步)
// 迁移历史数据到新分片
// 步骤3:读切流量
// 逐步将读流量切到新分片
// 步骤4:停止双写
// 完全切换到新分片,下线老分片

六、实战案例:电商订单系统#

架构设计#

// 1. 分片规则
- 订单表:按user_id哈希分片(4库32表)
- 订单明细表:按user_id分片(与订单表路由一致)
- 商家订单表:按shop_id分片(独立分片)
// 2. 表结构设计
CREATE TABLE order_0 (
order_id BIGINT PRIMARY KEY, -- Snowflake生成
user_id BIGINT NOT NULL, -- 分片键
shop_id BIGINT NOT NULL,
total_amount DECIMAL(10,2),
status TINYINT,
create_time DATETIME,
update_time DATETIME,
INDEX idx_user_time (user_id, create_time),
INDEX idx_status (status, create_time)
) ENGINE=InnoDB;
// 3. 查询优化
// 用户查订单:带分片键,单分片查询
SELECT * FROM order_X
WHERE user_id = 123 AND create_time > '2024-01-01';
// 商家查订单:需要冗余商家订单表
CREATE TABLE shop_order (
shop_id BIGINT, -- 分片键
order_id BIGINT,
user_id BIGINT,
create_time DATETIME,
INDEX idx_shop_time (shop_id, create_time)
);
// 4. 统计报表
// 方案:订单数据 → Binlog → Kafka → Flink → OLAP数据库
// 报表查询不走在线库

关键代码#

@Service
public class OrderService {
@Autowired
private ShardingStrategy shardingStrategy;
// 创建订单(单分片事务)
@Transactional
public Long createOrder(Order order) {
// 生成订单ID
long orderId = idGenerator.nextId();
order.setOrderId(orderId);
// 路由到分片
String tableName = shardingStrategy.getTableName(order.getUserId());
// 保存订单
orderDao.save(tableName, order);
// 保存订单明细(同一分片)
for (OrderItem item : order.getItems()) {
orderItemDao.save(tableName, item);
}
// 异步写入商家订单表
shopOrderProducer.send(order);
return orderId;
}
// 查询订单列表
public List<Order> queryUserOrders(Long userId, int page, int size) {
// 单分片查询
String tableName = shardingStrategy.getTableName(userId);
return orderDao.queryByUser(tableName, userId, page, size);
}
// 查询订单详情(通过order_id)
public Order getOrderById(Long orderId) {
// 从orderId中解析分片信息
Long userId = parseUserIdFromOrderId(orderId);
String tableName = shardingStrategy.getTableName(userId);
return orderDao.getById(tableName, orderId);
}
}

七、最佳实践总结#

设计原则#

  1. 能不拆就不拆:先优化 SQL、加缓存、读写分离
  2. 业务优先:根据业务特点选择分片策略
  3. 避免跨片:尽量让相关数据在同一分片
  4. 降级预案:准备好容灾和降级方案

技术选型#

  • 中间件:ShardingSphere、MyCat、TDDL
  • ID 生成:Snowflake、美团 Leaf、滴滴 Tinyid
  • 分布式事务:Seata、TCC、本地消息表

监控告警#

// 关键指标
- 各分片数据量分布
- 各分片QPS、RT
- 慢SQL监控
- 跨分片查询次数
- 分布式事务成功率

通过合理的分库分表策略,可以让数据库支撑从百万到数十亿级别的数据量,QPS 从几千提升到几十万。关键是要根据业务特点选择合适的方案,并做好监控和应急预案。

高并发场景下的数据库扩展策略
https://mizuki.mysqil.com/posts/盘牛客面经/快手一面实习/高并发场景下的数据库扩展策略/
作者
Laoli
发布于
2025-10-19
许可协议
CC BY-NC-SA 4.0
封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00