分库分表功能 DONE

This commit is contained in:
amos
2026-03-09 11:22:43 +08:00
parent 261918a973
commit fe8ca66ad5
2 changed files with 1406 additions and 0 deletions

View 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. 预留扩展空间(单号格式要考虑未来扩容)

File diff suppressed because it is too large Load Diff