分库分表功能 DONE
This commit is contained in:
393
docs/分库分表方案/业务单号设计最佳实践.md
Normal file
393
docs/分库分表方案/业务单号设计最佳实践.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# 业务单号设计最佳实践
|
||||
|
||||
## 核心问题
|
||||
|
||||
在分库分表场景下,如何设计业务单号才能支持:
|
||||
1. 按单号快速查询(无需遍历所有分片)
|
||||
2. 按用户维度查询(订单列表、凭证列表等)
|
||||
3. 第三方系统回调查询(如支付回调)
|
||||
|
||||
---
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案一:单号包含分片信息(推荐)
|
||||
|
||||
#### 设计思路
|
||||
|
||||
在业务单号中嵌入 userNo 信息,实现自路由。
|
||||
|
||||
#### 单号格式
|
||||
|
||||
```
|
||||
格式:{前缀}{时间戳}{userNo后6位}{序列号}
|
||||
|
||||
订单号:OD20240309123456789012001
|
||||
凭证号:TK20240309123456789012001
|
||||
支付单:PY20240309123456789012001
|
||||
|
||||
解析:
|
||||
- 前缀:2位,业务类型标识
|
||||
- 时间戳:14位,yyyyMMddHHmmss
|
||||
- userNo后6位:6位,用于分片路由
|
||||
- 序列号:3位,保证唯一性
|
||||
|
||||
总长度:25位
|
||||
```
|
||||
|
||||
#### 优点
|
||||
|
||||
✅ 无需额外路由表
|
||||
✅ 查询性能最优(直接路由到单个分片)
|
||||
✅ 实现简单
|
||||
✅ 单号可读性好(包含时间信息)
|
||||
|
||||
#### 缺点
|
||||
|
||||
❌ 单号长度较长(25位)
|
||||
❌ userNo 信息可能泄露(可通过加密解决)
|
||||
❌ userNo 变更时单号无法更新(实际场景很少)
|
||||
|
||||
#### 适用场景
|
||||
|
||||
- 大部分业务场景(推荐)
|
||||
- 对单号长度不敏感
|
||||
- userNo 相对稳定
|
||||
|
||||
---
|
||||
|
||||
### 方案二:路由表
|
||||
|
||||
#### 设计思路
|
||||
|
||||
单独维护一张路由表,记录 业务单号 → userNo/分片信息 的映射。
|
||||
|
||||
#### 路由表设计
|
||||
|
||||
```sql
|
||||
-- 订单路由表
|
||||
CREATE TABLE `order_route` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`order_no` VARCHAR(64) NOT NULL COMMENT '订单编号',
|
||||
`user_no` VARCHAR(64) NOT NULL COMMENT '用户编号',
|
||||
`db_index` TINYINT NOT NULL COMMENT '库索引',
|
||||
`table_index` TINYINT NOT NULL COMMENT '表索引',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_no` (`order_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 凭证路由表
|
||||
CREATE TABLE `ticket_route` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`ticket_no` VARCHAR(64) NOT NULL COMMENT '凭证编号',
|
||||
`user_no` VARCHAR(64) NOT NULL COMMENT '用户编号',
|
||||
`db_index` TINYINT NOT NULL COMMENT '库索引',
|
||||
`table_index` TINYINT NOT NULL COMMENT '表索引',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ticket_no` (`ticket_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 支付单路由表
|
||||
CREATE TABLE `payment_route` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`payment_no` VARCHAR(64) NOT NULL COMMENT '支付单编号',
|
||||
`third_party_no` VARCHAR(128) COMMENT '第三方流水号',
|
||||
`user_no` VARCHAR(64) NOT NULL COMMENT '用户编号',
|
||||
`db_index` TINYINT NOT NULL COMMENT '库索引',
|
||||
`table_index` TINYINT NOT NULL COMMENT '表索引',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_payment_no` (`payment_no`),
|
||||
UNIQUE KEY `uk_third_party_no` (`third_party_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
#### 查询流程
|
||||
|
||||
```java
|
||||
// 1. 查询路由表
|
||||
OrderRoute route = orderRouteRepository.findByOrderNo(orderNo);
|
||||
|
||||
// 2. 根据路由信息查询订单
|
||||
ShardingInfo sharding = new ShardingInfo(route.getDbIndex(), route.getTableIndex());
|
||||
Order order = orderMapper.selectByOrderNo(sharding, orderNo);
|
||||
```
|
||||
|
||||
#### 优点
|
||||
|
||||
✅ 单号格式灵活(不受分片规则限制)
|
||||
✅ 支持任意查询维度(如第三方流水号)
|
||||
✅ userNo 信息不泄露
|
||||
|
||||
#### 缺点
|
||||
|
||||
❌ 需要额外查询路由表(多一次 IO)
|
||||
❌ 路由表数据量大(与业务表同量级)
|
||||
❌ 需要维护路由表的一致性
|
||||
❌ 路由表本身也需要分表
|
||||
|
||||
#### 适用场景
|
||||
|
||||
- 单号格式有特殊要求
|
||||
- 需要支持多维度查询(如第三方流水号)
|
||||
- 对查询性能要求不是特别高
|
||||
|
||||
---
|
||||
|
||||
### 方案三:遍历所有分片(不推荐)
|
||||
|
||||
#### 设计思路
|
||||
|
||||
查询时遍历所有分片,直到找到数据。
|
||||
|
||||
#### 实现
|
||||
|
||||
```java
|
||||
public Order getOrderByOrderNo(String orderNo) {
|
||||
// 遍历所有分片
|
||||
for (int db = 0; db < 16; db++) {
|
||||
for (int table = 0; table < 16; table++) {
|
||||
Order order = orderMapper.selectByOrderNo(
|
||||
new ShardingInfo(db, table),
|
||||
orderNo
|
||||
);
|
||||
if (order != null) {
|
||||
return order;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
#### 优点
|
||||
|
||||
✅ 实现简单
|
||||
✅ 无需额外设计
|
||||
|
||||
#### 缺点
|
||||
|
||||
❌ 性能极差(最坏情况查询 256 次)
|
||||
❌ 数据库压力大
|
||||
❌ 响应时间不可控
|
||||
|
||||
#### 适用场景
|
||||
|
||||
- 仅用于临时查询、数据修复等场景
|
||||
- 不适合线上业务
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 核心业务单号:方案一(单号包含分片信息)
|
||||
|
||||
**适用于:**
|
||||
- 订单号
|
||||
- 凭证号
|
||||
- 支付单号
|
||||
- 退款单号
|
||||
|
||||
**理由:**
|
||||
- 查询性能最优
|
||||
- 实现简单
|
||||
- 无需额外维护
|
||||
|
||||
### 第三方关联单号:方案二(路由表)
|
||||
|
||||
**适用于:**
|
||||
- 第三方支付流水号
|
||||
- 第三方物流单号
|
||||
- 外部系统订单号
|
||||
|
||||
**理由:**
|
||||
- 第三方单号不可控
|
||||
- 必须支持反向查询
|
||||
- 路由表数据量相对较小
|
||||
|
||||
---
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 1. 单号生成器
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BusinessNoGenerator {
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Long> redisTemplate;
|
||||
|
||||
@Autowired
|
||||
private SnowflakeIdWorker snowflakeIdWorker;
|
||||
|
||||
/**
|
||||
* 生成业务单号(包含分片信息)
|
||||
*/
|
||||
public String generateBusinessNo(String prefix, String userNo) {
|
||||
// 时间戳:yyyyMMddHHmmss
|
||||
String timestamp = LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
|
||||
// userNo 后6位(用于路由)
|
||||
String userSuffix = extractUserSuffix(userNo);
|
||||
|
||||
// 序列号(3位)
|
||||
String sequence = String.format("%03d", getSequence(prefix + timestamp));
|
||||
|
||||
return prefix + timestamp + userSuffix + sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 userNo 后6位
|
||||
*/
|
||||
private String extractUserSuffix(String userNo) {
|
||||
// 如果 userNo 是数字
|
||||
if (userNo.matches("\\d+")) {
|
||||
long num = Long.parseLong(userNo);
|
||||
return String.format("%06d", num % 1000000);
|
||||
}
|
||||
|
||||
// 如果 userNo 是字符串,取 hashCode
|
||||
int hash = Math.abs(userNo.hashCode());
|
||||
return String.format("%06d", hash % 1000000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取序列号(Redis 自增)
|
||||
*/
|
||||
private Long getSequence(String key) {
|
||||
String redisKey = "seq:" + key;
|
||||
Long seq = redisTemplate.opsForValue().increment(redisKey);
|
||||
|
||||
// 设置过期时间(避免 key 过多)
|
||||
redisTemplate.expire(redisKey, 1, TimeUnit.DAYS);
|
||||
|
||||
return seq % 1000; // 3位序列号
|
||||
}
|
||||
|
||||
/**
|
||||
* 从业务单号解析分片信息
|
||||
*/
|
||||
public ShardingInfo parseShardingInfo(String businessNo) {
|
||||
// 提取 userNo 后缀(第16-21位)
|
||||
if (businessNo.length() < 22) {
|
||||
throw new IllegalArgumentException("Invalid businessNo: " + businessNo);
|
||||
}
|
||||
|
||||
String userSuffix = businessNo.substring(16, 22);
|
||||
long userNum = Long.parseLong(userSuffix);
|
||||
|
||||
int dbIndex = (int) (userNum % 16);
|
||||
int tableIndex = (int) ((userNum / 16) % 16);
|
||||
|
||||
return new ShardingInfo(dbIndex, tableIndex);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 单号安全性增强(可选)
|
||||
|
||||
如果担心 userNo 信息泄露,可以对 userNo 后缀进行简单加密:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 生成业务单号(加密版)
|
||||
*/
|
||||
public String generateBusinessNoWithEncryption(String prefix, String userNo) {
|
||||
String timestamp = LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
|
||||
// 提取 userNo 后6位
|
||||
String userSuffix = extractUserSuffix(userNo);
|
||||
|
||||
// 简单加密:异或运算
|
||||
long userNum = Long.parseLong(userSuffix);
|
||||
long encrypted = userNum ^ SECRET_KEY; // SECRET_KEY 是固定密钥
|
||||
String encryptedSuffix = String.format("%06d", encrypted % 1000000);
|
||||
|
||||
String sequence = String.format("%03d", getSequence(prefix + timestamp));
|
||||
|
||||
return prefix + timestamp + encryptedSuffix + sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密并解析分片信息
|
||||
*/
|
||||
public ShardingInfo parseShardingInfoWithDecryption(String businessNo) {
|
||||
String encryptedSuffix = businessNo.substring(16, 22);
|
||||
long encrypted = Long.parseLong(encryptedSuffix);
|
||||
|
||||
// 解密
|
||||
long userNum = encrypted ^ SECRET_KEY;
|
||||
|
||||
int dbIndex = (int) (userNum % 16);
|
||||
int tableIndex = (int) ((userNum / 16) % 16);
|
||||
|
||||
return new ShardingInfo(dbIndex, tableIndex);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 路由表优化
|
||||
|
||||
如果使用路由表,也需要对路由表进行分表:
|
||||
|
||||
```sql
|
||||
-- 按单号后4位分表(16张表)
|
||||
CREATE TABLE `order_route_0` LIKE `order_route`;
|
||||
CREATE TABLE `order_route_1` LIKE `order_route`;
|
||||
...
|
||||
CREATE TABLE `order_route_15` LIKE `order_route`;
|
||||
|
||||
-- 路由规则
|
||||
table_index = orderNo 后4位 % 16
|
||||
```
|
||||
|
||||
```java
|
||||
/**
|
||||
* 路由表查询
|
||||
*/
|
||||
public OrderRoute findByOrderNo(String orderNo) {
|
||||
// 计算路由表分片
|
||||
int routeTableIndex = calculateRouteTableIndex(orderNo);
|
||||
|
||||
// 查询对应的路由表
|
||||
return orderRouteMapper.selectByOrderNo(routeTableIndex, orderNo);
|
||||
}
|
||||
|
||||
private int calculateRouteTableIndex(String orderNo) {
|
||||
// 取单号后4位
|
||||
String suffix = orderNo.substring(orderNo.length() - 4);
|
||||
return Integer.parseInt(suffix) % 16;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 方案 | 查询次数 | 响应时间 | 数据库压力 | 实现复杂度 |
|
||||
|------|---------|---------|-----------|-----------|
|
||||
| 单号包含分片信息 | 1次 | <5ms | 低 | 低 |
|
||||
| 路由表 | 2次 | <10ms | 中 | 中 |
|
||||
| 遍历所有分片 | 1-256次 | 不可控 | 高 | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
**推荐方案:单号包含分片信息 + 路由表(混合模式)**
|
||||
|
||||
- 订单号、凭证号、支付单号:包含分片信息(方案一)
|
||||
- 第三方流水号:使用路由表(方案二)
|
||||
|
||||
这样既保证了核心查询的性能,又支持了第三方系统的回调查询。
|
||||
|
||||
**关键设计原则:**
|
||||
1. 优先使用自路由单号(性能最优)
|
||||
2. 不可控的外部单号使用路由表
|
||||
3. 路由表本身也要分表
|
||||
4. 考虑单号的安全性(可选加密)
|
||||
5. 预留扩展空间(单号格式要考虑未来扩容)
|
||||
1013
docs/分库分表方案/订单系统分表设计.md
Normal file
1013
docs/分库分表方案/订单系统分表设计.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user