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_3db_1: order_0, order_1, order_2, order_3db_2: order_0, order_1, order_2, order_3db_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 orderWHERE order_time > '2024-10-12'ORDER BY order_time DESCLIMIT 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 → 实时同步到ES2. 分布式事务
问题:一个业务操作涉及多个分片
场景示例:转账
// 用户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 分布式事务框架
@GlobalTransactionalpublic 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:本地事务 + 消息表@Transactionalpublic 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_XWHERE 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数据库// 报表查询不走在线库关键代码
@Servicepublic 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); }}七、最佳实践总结
设计原则
- 能不拆就不拆:先优化 SQL、加缓存、读写分离
- 业务优先:根据业务特点选择分片策略
- 避免跨片:尽量让相关数据在同一分片
- 降级预案:准备好容灾和降级方案
技术选型
- 中间件:ShardingSphere、MyCat、TDDL
- ID 生成:Snowflake、美团 Leaf、滴滴 Tinyid
- 分布式事务:Seata、TCC、本地消息表
监控告警
// 关键指标- 各分片数据量分布- 各分片QPS、RT- 慢SQL监控- 跨分片查询次数- 分布式事务成功率通过合理的分库分表策略,可以让数据库支撑从百万到数十亿级别的数据量,QPS 从几千提升到几十万。关键是要根据业务特点选择合适的方案,并做好监控和应急预案。
高并发场景下的数据库扩展策略
https://mizuki.mysqil.com/posts/盘牛客面经/快手一面实习/高并发场景下的数据库扩展策略/