数据库分片前的关键考量
- 水平分片通过将单一数据库表拆分到不同物理节点解决性能瓶颈,通过对表进行横向拆分实现数据分布式存储
- 与MySQL分区表(单节点)不同,分片后数据分布于独立服务器
- 除非写入负载远超单节点上限(如日增百万级数据),否则应优先优化SQL、索引或架构设计
分片显著增加运维复杂度,需作为终极扩容方案
实施前需重点评估以下问题:
1 ) 分片必要性评估
- 水平分片通过将单个数据库表分布到不同物理节点解决性能瓶颈,与MySQL分区表本质区别在于:
- 物理分布差异:分区表位于同一节点同数据库,分片后数据存在于不同物理节点
- 实现复杂度:分片需解决跨节点查询、数据路由等分布式问题,复杂度显著高于分区表
- 在数据库负载未达瓶颈时,应优先考虑性能调优与架构优化(如索引优化、查询重构)
- 分片会显著增加系统复杂度与维护成本,仅当单节点无法支撑业务增长时采用
- 何时分片:仅当满足以下条件且无法通过优化解决时:
- 单表数据量 > 5000万行
- 写QPS > 5000
- 磁盘IO利用率持续 > 80%
2 ) 分区键(Sharding Key)设计原则
分区键直接决定查询效率与数据均衡性,需满足:
- 避免跨片查询:
- OLTP场景应确保90%以上查询含分区键
- OLTP场景中跨片查询需合并多节点结果,性能通常低于未分片状态
- 示例:博客系统按
user_id分片可使用户数据集中存储,消除按article_id分片导致的跨片聚合问题-- 反例:按文章ID分片导致用户查询需扫描全部分片 SELECT * FROM articles WHERE author_id=101; -- 正例:按作者ID分片可定位单分片查询 SELECT * FROM articles WHERE author_id=101 AND shard_key=101;
- 数据均衡性
- 采用哈希函数(如
CRC32)确保数据均匀分布 - 若按数值范围分片(如订单ID区间),需警惕热点数据倾斜问题
- 如某课程题库80%访问集中在单个分片
- 保障数据均匀分布:热点数据集中会导致分片负载失衡。推荐哈希分片优于范围分片:
# 哈希分片算法示例 shard_id = hash(shard_key) % total_shards
- 采用哈希函数(如
- 数据均衡策略:
分片方式 适用场景 缺陷 哈希取模 离散型数据(用户ID) 扩容需数据迁移 范围分片 连续数据(时间序列) 易产生热点分片 路由表映射 需精准控制数据位置 需维护元数据缓存 - 关联表一致性
- 分区键需在关联表间对齐
- 订单表与订单商品表必须使用相同分区键(如
order_id),否则关联查询将失效
3 ) 非分片表存储策略
| 策略类型 | 适用场景 | 优缺点 |
|---|---|---|
| 冗余存储 | 字典表(如商品分类) 数据量小、更新频率低 | ✅ 关联查询高效 ❌ 需多写同步与定期一致性校验 |
| 中央存储 | 独立业务表(如用户信息) 高频更新表 | ✅ 无数据冗余 ❌ 跨片关联需程序层合并结果 |
换个角度
| 策略 | 适用场景 | 一致性风险 |
|---|---|---|
| 分片冗余存储 | 小型字典表(如商品分类) | 需多写同步,定期校验数据一致性 |
| 中央库集中存储 | 低频更新的大表 | 跨片关联查询性能较低 |
以及
| 方案 | 一致性风险 | 查询性能 | 适用场景 |
|---|---|---|---|
| 分片冗余存储 | 高 | 高 | <100KB的字典表 |
| 中心化存储 | 低 | 中 | 更新频繁的大表 |
两类存储策略对比:
-- 方案1:全分片冗余存储(字典表)
CREATE TABLE category_global (
id INT PRIMARY KEY,
name VARCHAR(50)
) ENGINE=InnoDB;
-- 需同步更新所有分片
UPDATE shard_001.category SET name='电子产品' WHERE id=101;
UPDATE shard_002.category SET name='电子产品' WHERE id=101;
-- 方案2:中心化存储(关联查询示例)
SELECT o.order_id, c.name
FROM orders o
JOIN central_db.category c ON o.category_id = c.id;
4 )分片部署与命名规范
| 部署模式 | 命名规则示例 | 特点 |
|---|---|---|
| 单节点单库单分片 | orders(各分片同名) | 结构与原库一致,无需修改 SQL 表名 |
| 单库多分片表 | orders_0、orders_1 | 表名添加分片后缀,需修改 SQL 查询语句 |
| 单节点多库 | db0_orders、db1_orders | 数据库名添加分片编号,支持多套分片表 |
注意事项:
- 需严格防止数据写入错误分片(如
user_id=101应写入分片1,误写入分片2)。
5 ) 分片部署拓扑
分片部署模式
- 物理节点级隔离:单节点部署单分片库(如
db_shard1) - 逻辑表级隔离:单库多表后缀区分(如
orders_01,orders_02)-- 节点1创建分片表 CREATE TABLE orders_01 ( order_id INT PRIMARY KEY, -- 禁用自增ID amount DECIMAL(10,2), create_time DATETIME );
6 ) 分片数据分配策略
| 策略 | 实现方式 | 优缺点 |
|---|---|---|
| 哈希取模 | HASH(shard_key) % N | ✅ 数据分布均匀 ❌ 难以人工干预数据位置 |
| 范围分配 | shard_key BETWEEN 1-100 → 分片0 | ✅ 数据位置可控 ❌ 易导致数据/负载倾斜(如热点课程集中访问) |
| 映射表配置 | 查表获取分片位置 | ✅ 灵活控制数据存储位置 ❌ 映射表需缓存(如 Redis)防性能瓶颈 |
映射表示例:
CREATE TABLE shard_mapping (
user_id INT PRIMARY KEY,
shard_id INT NOT NULL -- 分片编号
);
-- 查询 user_id=101 所在分片
SELECT shard_id FROM shard_mapping WHERE user_id = 101;
NestJS 中缓存映射表(Redis):
import { RedisService } from '@nestjs-modules/ioredis';
@Injectable()
export class ShardService {
constructor(private readonly redis: RedisService) {}
async getShardId(userId: number): Promise<number> {
const cachedShardId = await this.redis.get(`shard:${userId}`);
if (cachedShardId) return parseInt(cachedShardId);
const shardId = await this.db.query(`SELECT shard_id FROM shard_mapping WHERE user_id = ${userId}`);
await this.redis.set(`shard:${userId}`, shardId, 'EX', 3600); // 缓存1小时
return shardId;
}
}
7 ) 分布式ID生成方案
| 方案 | 实现方式 | 适用场景 | 局限 |
|---|---|---|---|
| 步长偏移 | 配置auto_increment_increment=分片数auto_increment_offset=分片序号 | 单节点单分片表 | 仅适用单分片单表场景 |
| 全局序列表 | 中央节点维护ID生成表 | 架构简单,存在性能瓶颈 | 存在单点性能瓶颈 |
| Redis原子操作 | INCR命令生成唯一ID | 高性能场景,需缓存集群 | 需保证Redis高可用 |
单节点 AUTO_INCREMENT 在分片后会导致 ID 冲突,解决方案如下:
1 ) 调整自增参数:
- 设置
auto_increment_increment = N(分片数) - 设置
auto_increment_offset = 1,2,...N(分片偏移量) - 限制:仅适用于单节点单套分片表,多套分片表仍会冲突。
2 ) 全局ID表:
-- 中心节点建表
CREATE TABLE global_id (
id BIGINT AUTO_INCREMENT PRIMARY KEY
);
-- 应用获取ID
INSERT INTO global_id VALUES ();
SELECT LAST_INSERT_ID();
缺点:中心节点易成性能瓶颈。
3 ) Redis 生成分布式ID:
// NestJS + Redis 实现
@Injectable()
export class IdService {
constructor(private readonly redis: RedisService) {}
async generateId(key: string): Promise<number> {
return this.redis.incr(`id:${key}`);
}
}
优势:利用 Redis 单线程原子性避免冲突,高性能。
基于OneProxy的分片实战演示(架构篇)
OneProxy安装与核心配置
安装步骤:
下载预编译包(非开源)
wget http://www.onexsoft.com/software/oneproxy-rhel6-latest.tar.gz
# 解压安装
tar -zxvf oneproxy-rhel5-latest.tar.gz -C /usr/local/
cd /usr/local/oneproxy
# 服务化启动
cp demo.sh /etc/init.d/oneproxy
chkconfig --add oneproxy
实验环境拓扑
应用层 → OneProxy(192.168.1.102:3306)
├─ 分片节点1 (192.168.1.100:3306)
└─ 分片节点2 (192.168.1.101:3306)
- 中间件:OneProxy(非开源)提供分片路由、连接池管理,不支持分布式事务
- 解决方案:基于:OneProxy + TCC + 消息队列的架构
- OneProxy 仅承担数据路由,避免跨库事务
- TCC 在应用层实现事务逻辑,通过消息队列保证最终一致性
- 通过幂等设计、状态跟踪、强制主库路由解决一致性风险
- 这里不进行展开,不是主线任务
- 分片节点(2个物理节点):
- 节点一:IP
192.168.1.100- 数据库:
orders_01 - 物理表:
order_detail_01(订单表)、order_product_01(订单商品表)、category_global(分类表)
- 数据库:
- 节点二:IP
192.168.1.101- 数据库:
orders_02 - 物理表:
order_detail_02、order_product_02、category_global
- 数据库:
- 节点一:IP
- OneProxy中间件节点:IP
192.168.1.102- 代理端口:
3306(模拟MySQL协议) - 管理端口:
4041(配置加密/监控)
- 代理端口:
- 关键设计:
- 订单表与订单商品表按分片键
orderID水平拆分 - 分类表作为全局表(
global table)全量复制到所有节点
- 订单表与订单商品表按分片键
1 ) 分片表结构设计
节点1:
-- 节点1:订单分片表
CREATE DATABASE IF NOT EXISTS orders_01;
USE orders_01;
-- 节点1:订单表(分片表)
CREATE TABLE order_detail_01 (
order_id INT PRIMARY KEY, -- 禁用auto_increment
order_time DATETIME NOT NULL,
amount DECIMAL(10,2) NOT NULL
) ENGINE=InnoDB;
-- 节点1:订单商品表(分片表)
CREATE TABLE order_product_01 (
order_id INT NOT NULL, -- ️与订单表一致
product_id INT NOT NULL,
PRIMARY KEY (order_id, product_id)
) ENGINE=InnoDB;
-- 节点1:全局分类表
CREATE TABLE category_global (
category_id INT PRIMARY KEY,
category_name VARCHAR(50) NOT NULL
) ENGINE=InnoDB;
节点2:(仅变更表后缀)
USE orders_02;
CREATE TABLE order_detail_02 (...); -- 结构同orderdetail_01
CREATE TABLE order_product_02 (...); -- 结构同orderproduct_01
CREATE TABLE category_global (...); -- 结构同节点一
2 ) OneProxy核心配置
# oneproxy.cnf
[mysql-proxy]
daemon = true
event-threads = 4
log-file = /var/log/oneproxy.log
log-level = debug # 查看SQL改写日志
proxy-address = :3306
proxy-master-addresses = 192.168.1.100:3306@orders_01
proxy-master-addresses = 192.168.1.101:3306@orders_02
# proxy-slave-addresses = 192.168.1.103:3306@slave01 # 新增从库 后续可能得增加
admin-address = 127.0.0.1:4041 # 启用管理接口
proxy-part-tables = /etc/oneproxy/conf/order_part.txt # 建议绝对路径
proxy-user-list = test:0A8C9F3E5D:B3A9E8F7C5 # 加密密码通过管理端口生成
charset = utf8mb4
proxy-check-interval = 2000 # 2秒健康检查
密码加密操作:
-- 连接OneProxy管理端
mysql -h127.0.0.1 -P4041 -uadmin -poneproxy
-- 生成加密密码
ONEPROXY> password 123456;
-- 返回:2B3C5D7E9A1F4C8D(替换配置文件中的明文)
3 )分片规则定义
# ===== 分片表配置(哈希分片)=====
order_part.txt
[order_detail]
sharding-key = order_id # 分片键(整数类型)
sharding-type = int # 键值类型
sharding-method = hash # 哈希算法
# 逻辑分片与物理节点映射(格式:逻辑库:物理表)
sharding-partitions = orders_01:order_detail_01, orders_02:order_detail_02
# 物理节点定义(精简冗余参数)
shard-node-01 = 192.168.1.100:3306:orders_01:order_detail_01
shard-node-02 = 192.168.1.101:3306:orders_02:order_detail_02
[order_product]
sharding-key = order_id
sharding-type = int
sharding-method = hash
sharding-partitions = orders_01:order_product_01, orders_02:order_product_02
shard-node-01 = 192.168.1.100:3306:orders_01:order_product_01
shard-node-02 = 192.168.1.101:3306:orders_02:order_product_02
# ===== 全局表配置(全节点同步)=====
[category]
sharding-method = global
# 统一全局表命名规范(添加 _global 后缀避免混淆)
shard-node-01 = 192.168.1.100:3306:orders_01:category_global # 全节点同步
shard-node-02 = 192.168.1.101:3306:orders_02:category_global
或
shard_tables {{
# 定义全局节点
global_nodes = [
"192.168.1.100:3306:orders_01",
"192.168.1.101:3306:orders_02"
]
# 分片表配置 - order_detail
order_detail {{
partition_key = "order_id"
partition_type = "int"
partition_method = "hash"
# 显式声明物理表名(避免拼接歧义)
partition_sources = [
{{ physical_table = "order_detail_01", group = "orders_01" }},
{{ physical_table = "order_detail_02", group = "orders_02" }}
]
# 补充物理节点定义
nodes = global_nodes // 引用全局节点
}}
# 分片表配置 - order_product
order_product {{
partition_key = "order_id"
partition_type = "int"
partition_method = "hash"
# 显式声明物理表名(避免拼接歧义)
partition_sources = [
{{ physical_table = "order_product_01", group = "orders_01" }},
{{ physical_table = "order_product_02", group = "orders_02" }}
]
# 补充物理节点定义
nodes = global_nodes // 引用全局节点
}}
# 全局表配置 - category
category {{
partition_method = "global"
# 强制添加_global后缀并指定物理表
physical_table = "category_global"
# 显式定义广播节点
replicate_nodes = global_nodes // 引用全局节点
}}
}}
OneProxy 通过动态路由机制处理逻辑对象映射,无需显式配置逻辑库名
OneProxy 设计精髓: 逻辑层(客户端查询)与物理层(配置规则)解耦,通过分片键实现自动化路由,而非依赖命名约定
分片键一致性:关联表必须使用相同分片键,避免跨节点JOIN(如订单表与订单商品表均用orderID)
分片操作验证与故障规避(实践篇)
1 )分片数据写入测试
#!/bin/bash
test.sh 模拟分片写入
order_id=1
while true; do
mysql -utest -p123456 -h192.168.1.102 -e \
"INSERT INTO orders.order_detail (order_id, order_time, amount)
VALUES ($order_id, NOW(), 100.00);
INSERT INTO orders.order_product (order_id, product_id)
VALUES ($order_id, $order_id*10);
INSERT INTO orders.category (category_id, category_name)
VALUES ($order_id, 'cat_$order_id');"
((order_id++))
done
2 ) 数据分布验证
-- 通过OneProxy查询总量
SELECT COUNT(*) FROM order_detail; -- 返回188(自动聚合分片数据)
-- 直连分片节点验证
SELECT COUNT(*) FROM order_detail_01; -- 节点1返回94
SELECT COUNT(*) FROM order_detail_02; -- 节点2返回94
3 ) 全局表一致性风险
-- 节点1分类表
SELECT MAX(category_id) FROM category_global; -- 返回189
-- 节点2分类表
SELECT MAX(category_id) FROM category_global; -- 返回188(因非事务更新导致不一致)
规避方案:通过定时任务校验修复
CREATE EVENT check_category
ON SCHEDULE EVERY 1 HOUR
DO
-- 数据修复逻辑
REPLACE INTO node2.category_global
SELECT * FROM node1.category_global
WHERE category_id NOT IN (SELECT category_id FROM node2.category_global);
上述 sql 只是一个 大概的意思, 就是将不同主机的两库通过定时任务同步, 也可以用程序来做, 例如:
额外开发双写校验工具,定期修复数据不一致问题
// NestJS全局表修复示例(TypeScript)
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Injectable()
export class ShardSyncService {
constructor(
@InjectDataSource('node1') private node1: DataSource,
@InjectDataSource('node2') private node2: DataSource
) {}
async fixCategoryTable(): Promise<void> {
const [node1Data, node2Data] = await Promise.all([
this.node1.query(`SELECT * FROM category_global`),
this.node2.query(`SELECT * FROM category_global`)
]);
const discrepancies = node1Data.filter(item =>
!node2Data.some(x => x.cat_id === item.cat_id)
);
for (const record of discrepancies) {
await this.node2.query(`
REPLACE INTO category VALUES (?, ?)
`, [record.cat_id, record.cat_name]);
}
}
}
或改用中央库存储策略,这里不展开
4 )三种ID生成策略对比:
// NestJS + Redis ID生成服务
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class IdGeneratorService {
private redis = new Redis({ host: 'redis-host' });
async getNextId(key: string): Promise<number> {
return this.redis.incr(`id_gen:${key}`);
}
}
// 分片写入示例
const shardId = orderId % SHARD_COUNT;
const shardTable = `order_${shardId}`;
| 方案 | 吞吐量 | 缺点 |
|---|---|---|
| MySQL自增步长设置 | 1k QPS | 节点数固定难扩容 |
| 独立ID生成表 | 5k QPS | 单点瓶颈 |
| Redis分布式ID | 50k QPS | 需缓存高可用 |
关键结论与最佳实践
1 ) 分片决策树
或参考下面方案
黄金准则:分片是最后的扩展手段,优先尝试:
- 读写分离架构
- 冷热数据分离
- 查询重写优化
2 ) 分片适用边界
仅当单节点写性能达到物理极限(如SSD IOPS饱和)、且纵向扩展无效时启用分片
3 ) 分片键设计铁律
- 必须保证关联表分片键一致性
- 优先选择高基数字段(如订单ID而非用户ID)
- 关联表必须同分区键
- 优先选哈希分片(如
CRC32(shard_key)%1024) - OLAP类查询需走独立汇总库
4 ) 全局表同步风险
采用双写+校验修正机制:
// 全局表双写示例
async updateCategory(catId: number, name: string) {
await this.dbShard1.update(catId, {name});
await this.dbShard2.update(catId, {name}); // 非事务操作
this.checkConsistency(catId); // 异步校验
}
5 )分片扩容瓶颈
初始分片数需预留2倍以上增长空间,避免重分片导致业务中断
哈希取模扩容问题:
原分片数:N
扩容后分片数:M
数据迁移量 ≈ 1 - N/M (如10片扩20片需迁移50%数据)
优化方案:
- 一致性哈希:仅迁移 K/N 数据(K为节点数)
- 虚拟桶映射:通过路由表解耦物理分片
6 ) OneProxy局限性
- 全局表同步需自行实现
- 分片路由规则不支持动态更新
- 复杂查询需应用层结果聚合
7 ) 分片回退策略
在NestJS中实现分片降级机制:
// order.service.ts
async createOrder(dto: CreateOrderDto) {
try {
// 分片写入
return await this.shardRepository.insert(dto);
} catch (e) {
if (e instanceof ShardTimeoutError) {
// 降级至中央写入
return this.centralRepository.insert(dto);
}
throw e;
}
}
8 )简单总结
- 分区键选择:以业务查询模式为核心,优先避免跨分片操作
- 分片表需严格统一分片键,确保关联查询本地化
- 数据均匀性:通过哈希或映射表平衡分片负载,警惕范围分片的热点问题
- 全局表存在数据不一致风险,需配套校验机制
- 唯一ID生成:分布式场景推荐 Redis 原子计数 或 Snowflake 算法
- 分片部署一致性:明确命名规范,防止数据误路由
- 非分片表策略:小规模静态表冗余存储,大规模动态表公共节点存储
- OneProxy适用于简单分片场景,分布式事务需定制开发
- 分片是最终手段,优先考虑索引优化、归档冷数据等方案
注:数据库分片是扩展性优化的终极手段,需严格遵循“先优化,再分库,最后分片” 的实施路径
任何分片方案必须包含数据一致性保障与故障回退机制
核心原则:分片是最后手段,优先优化索引、缓存与架构设计。
若必须分片,需严格验证分区键选择与数据分布策略,并通过压测模拟真实负载
最后强调:分片是牺牲运维便捷性换取扩展性的妥协方案。在SSD/NVMe普及的当下,单机MySQL可支撑数万TPS,应优先探索内存计算、列式存储或TiDB等NewSQL方案
1万+

被折叠的 条评论
为什么被折叠?



