10 KiB
10 KiB
业务单号设计最佳实践
核心问题
在分库分表场景下,如何设计业务单号才能支持:
- 按单号快速查询(无需遍历所有分片)
- 按用户维度查询(订单列表、凭证列表等)
- 第三方系统回调查询(如支付回调)
方案对比
方案一:单号包含分片信息(推荐)
设计思路
在业务单号中嵌入 userNo 信息,实现自路由。
单号格式
格式:{前缀}{时间戳}{userNo后6位}{序列号}
订单号:OD20240309123456789012001
凭证号:TK20240309123456789012001
支付单:PY20240309123456789012001
解析:
- 前缀:2位,业务类型标识
- 时间戳:14位,yyyyMMddHHmmss
- userNo后6位:6位,用于分片路由
- 序列号:3位,保证唯一性
总长度:25位
优点
✅ 无需额外路由表 ✅ 查询性能最优(直接路由到单个分片) ✅ 实现简单 ✅ 单号可读性好(包含时间信息)
缺点
❌ 单号长度较长(25位) ❌ userNo 信息可能泄露(可通过加密解决) ❌ userNo 变更时单号无法更新(实际场景很少)
适用场景
- 大部分业务场景(推荐)
- 对单号长度不敏感
- userNo 相对稳定
方案二:路由表
设计思路
单独维护一张路由表,记录 业务单号 → userNo/分片信息 的映射。
路由表设计
-- 订单路由表
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;
查询流程
// 1. 查询路由表
OrderRoute route = orderRouteRepository.findByOrderNo(orderNo);
// 2. 根据路由信息查询订单
ShardingInfo sharding = new ShardingInfo(route.getDbIndex(), route.getTableIndex());
Order order = orderMapper.selectByOrderNo(sharding, orderNo);
优点
✅ 单号格式灵活(不受分片规则限制) ✅ 支持任意查询维度(如第三方流水号) ✅ userNo 信息不泄露
缺点
❌ 需要额外查询路由表(多一次 IO) ❌ 路由表数据量大(与业务表同量级) ❌ 需要维护路由表的一致性 ❌ 路由表本身也需要分表
适用场景
- 单号格式有特殊要求
- 需要支持多维度查询(如第三方流水号)
- 对查询性能要求不是特别高
方案三:遍历所有分片(不推荐)
设计思路
查询时遍历所有分片,直到找到数据。
实现
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. 单号生成器
@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 后缀进行简单加密:
/**
* 生成业务单号(加密版)
*/
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. 路由表优化
如果使用路由表,也需要对路由表进行分表:
-- 按单号后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
/**
* 路由表查询
*/
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次 | 不可控 | 高 | 低 |
总结
推荐方案:单号包含分片信息 + 路由表(混合模式)
- 订单号、凭证号、支付单号:包含分片信息(方案一)
- 第三方流水号:使用路由表(方案二)
这样既保证了核心查询的性能,又支持了第三方系统的回调查询。
关键设计原则:
- 优先使用自路由单号(性能最优)
- 不可控的外部单号使用路由表
- 路由表本身也要分表
- 考虑单号的安全性(可选加密)
- 预留扩展空间(单号格式要考虑未来扩容)