diff --git a/docs/分库分表方案/业务单号设计最佳实践.md b/docs/分库分表方案/业务单号设计最佳实践.md new file mode 100644 index 0000000..ba0fb23 --- /dev/null +++ b/docs/分库分表方案/业务单号设计最佳实践.md @@ -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 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. 预留扩展空间(单号格式要考虑未来扩容) diff --git a/docs/分库分表方案/订单系统分表设计.md b/docs/分库分表方案/订单系统分表设计.md new file mode 100644 index 0000000..5279ef7 --- /dev/null +++ b/docs/分库分表方案/订单系统分表设计.md @@ -0,0 +1,1013 @@ +# 订单系统分库分表设计方案 + +## 业务场景分析 + +### 核心查询场景 + +**订单表 (order)** +- 订单列表:根据 userNo + 时间范围 + 订单类型 + 订单状态查询 +- 订单详情:根据 orderNo 查询 + +**凭证表 (ticket)** +- 根据 orderNo 查询 +- 根据 userNo + 状态查询 + +**支付单表 (payment)** +- 根据 orderNo 查询 +- 根据 paymentNo 查询 +- 根据 类型 + 状态查询 + +### 数据量预估 + +假设: +- 日订单量:100万 +- 年订单量:3.6亿 +- 3年数据:10亿+ + +--- + +## 分表策略 + +### 核心原则 + +1. **按用户维度分表**:大部分查询都带 userNo +2. **保持关联关系**:订单、凭证、支付单使用相同的分片键 +3. **支持多维度查询**:通过冗余表或索引表解决 + +--- + +## 方案设计 + +### 方案一:按 userNo 分库分表(推荐) + +#### 分片规则 + +``` +分片数:16 个库 × 16 张表 = 256 个分片 + +路由规则: +db_index = userNo % 16 +table_index = (userNo / 16) % 16 + +示例: +userNo = 123456 +db_index = 123456 % 16 = 0 → order_db_0 +table_index = (123456 / 16) % 16 = 8 → order_8 +``` + +#### 表结构设计 + +##### 1. 订单表 (order_0 ~ order_15) + +```sql +CREATE TABLE `order_{0-15}` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `order_no` VARCHAR(64) NOT NULL COMMENT '订单编号(全局唯一)', + `user_no` VARCHAR(64) NOT NULL COMMENT '用户编号(分片键)', + `order_type` TINYINT NOT NULL COMMENT '订单类型:1-实时公交 2-定制巴士', + `order_status` TINYINT NOT NULL COMMENT '订单状态:1-待支付 2-已支付 3-已完成 4-已取消', + `route_id` VARCHAR(64) COMMENT '线路ID', + `schedule_id` VARCHAR(64) COMMENT '班次ID', + `passenger_count` INT COMMENT '乘客数量', + `total_amount` DECIMAL(10,2) COMMENT '订单金额', + `create_time` DATETIME NOT NULL COMMENT '创建时间', + `update_time` DATETIME NOT NULL COMMENT '更新时间', + `pay_time` DATETIME COMMENT '支付时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_user_create` (`user_no`, `create_time`), + KEY `idx_user_status` (`user_no`, `order_status`, `create_time`), + KEY `idx_user_type` (`user_no`, `order_type`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; +``` + +**索引说明:** +- `uk_order_no`:支持按订单号查询详情 +- `idx_user_create`:支持用户订单列表(按时间排序) +- `idx_user_status`:支持按状态筛选 +- `idx_user_type`:支持按类型筛选 + +##### 2. 凭证表 (ticket_0 ~ ticket_15) + +```sql +CREATE TABLE `ticket_{0-15}` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `ticket_no` VARCHAR(64) NOT NULL COMMENT '凭证编号(全局唯一)', + `order_no` VARCHAR(64) NOT NULL COMMENT '订单编号', + `user_no` VARCHAR(64) NOT NULL COMMENT '用户编号(分片键)', + `ticket_type` TINYINT NOT NULL COMMENT '凭证类型:1-单程票 2-往返票', + `ticket_status` TINYINT NOT NULL COMMENT '凭证状态:1-未使用 2-已使用 3-已过期 4-已作废', + `route_id` VARCHAR(64) COMMENT '线路ID', + `schedule_id` VARCHAR(64) COMMENT '班次ID', + `seat_no` VARCHAR(32) COMMENT '座位号', + `qr_code` VARCHAR(256) COMMENT '二维码', + `valid_start_time` DATETIME COMMENT '有效期开始', + `valid_end_time` DATETIME COMMENT '有效期结束', + `use_time` DATETIME COMMENT '使用时间', + `create_time` DATETIME NOT NULL COMMENT '创建时间', + `update_time` DATETIME NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ticket_no` (`ticket_no`), + KEY `idx_order_no` (`order_no`), + KEY `idx_user_status` (`user_no`, `ticket_status`, `create_time`), + KEY `idx_user_create` (`user_no`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证表'; +``` + +**索引说明:** +- `uk_ticket_no`:支持按凭证号查询 +- `idx_order_no`:支持按订单号查询凭证 +- `idx_user_status`:支持用户凭证列表(按状态筛选) + +##### 3. 支付单表 (payment_0 ~ payment_15) + +```sql +CREATE TABLE `payment_{0-15}` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `payment_no` VARCHAR(64) NOT NULL COMMENT '支付单编号(全局唯一)', + `order_no` VARCHAR(64) NOT NULL COMMENT '订单编号', + `user_no` VARCHAR(64) NOT NULL COMMENT '用户编号(分片键)', + `payment_type` TINYINT NOT NULL COMMENT '支付类型:1-支付 2-退款', + `payment_status` TINYINT NOT NULL COMMENT '支付状态:1-待支付 2-支付中 3-支付成功 4-支付失败', + `payment_channel` VARCHAR(32) COMMENT '支付渠道:ALIPAY/WECHAT/BALANCE', + `payment_amount` DECIMAL(10,2) NOT NULL COMMENT '支付金额', + `third_party_no` VARCHAR(128) COMMENT '第三方支付流水号', + `callback_time` DATETIME COMMENT '回调时间', + `create_time` DATETIME NOT NULL COMMENT '创建时间', + `update_time` DATETIME NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_payment_no` (`payment_no`), + KEY `idx_order_no` (`order_no`), + KEY `idx_user_create` (`user_no`, `create_time`), + KEY `idx_user_type_status` (`user_no`, `payment_type`, `payment_status`), + KEY `idx_third_party` (`third_party_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付单表'; +``` + +**索引说明:** +- `uk_payment_no`:支持按支付单号查询 +- `idx_order_no`:支持按订单号查询支付单 +- `idx_user_type_status`:支持按类型和状态筛选 +- `idx_third_party`:支持第三方回调查询 + +--- + +### 方案二:订单号路由索引表(解决按 orderNo 查询) + +#### 问题 + +按 userNo 分表后,如果只有 orderNo,无法确定在哪个分片。 + +#### 解决方案:订单号路由表 + +```sql +-- 单独的路由表(不分表或按 orderNo 分表) +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 COMMENT='订单路由表'; +``` + +**使用方式:** + +```java +@Service +public class OrderQueryService { + + /** + * 根据订单号查询订单详情 + */ + public Order getOrderByOrderNo(String orderNo) { + // 1. 从路由表查询 userNo + OrderRoute route = orderRouteRepository.findByOrderNo(orderNo); + if (route == null) { + return null; + } + + // 2. 根据 userNo 路由到具体分片查询 + return orderRepository.findByOrderNo(route.getUserNo(), orderNo); + } +} +``` + +**优化:路由表也可以分表** + +```sql +-- 按 orderNo 后4位分表(16张表) +CREATE TABLE `order_route_{0-15}` ( + ... +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 路由规则 +table_index = orderNo 后4位 % 16 +``` + +--- + +### 方案三:业务单号中包含 userNo 信息(推荐) + +#### 统一编号生成规则 + +所有业务单号(订单号、凭证号、支付单号)都包含 userNo 信息,实现自路由。 + +##### 1. 订单号生成规则 + +``` +订单号格式:{业务前缀}{时间戳}{userNo后6位}{序列号} +示例:OD20240309123456789012001 + +解析: +- OD: 订单前缀 +- 20240309: 日期 +- 123456: 时间(时分秒) +- 789012: userNo 后6位(用于路由) +- 001: 序列号 +``` + +##### 2. 凭证号生成规则 + +``` +凭证号格式:{业务前缀}{时间戳}{userNo后6位}{序列号} +示例:TK20240309123456789012001 + +解析: +- TK: 凭证前缀 (Ticket) +- 20240309: 日期 +- 123456: 时间(时分秒) +- 789012: userNo 后6位(用于路由) +- 001: 序列号 +``` + +##### 3. 支付单号生成规则 + +``` +支付单号格式:{业务前缀}{时间戳}{userNo后6位}{序列号} +示例:PY20240309123456789012001 + +解析: +- PY: 支付单前缀 (Payment) +- 20240309: 日期 +- 123456: 时间(时分秒) +- 789012: userNo 后6位(用于路由) +- 001: 序列号 +``` + +**核心优点:** +- 所有业务单号都支持自路由 +- 无需额外的路由表 +- 直接从单号解析出分片信息 +- 性能最优 + +**实现:** + +```java +/** + * 统一业务单号生成器 + */ +@Service +public class BusinessNoGenerator { + + @Autowired + private RedisTemplate redisTemplate; + + /** + * 生成订单号 + */ + public String generateOrderNo(String userNo) { + return generateBusinessNo("OD", userNo); + } + + /** + * 生成凭证号 + */ + public String generateTicketNo(String userNo) { + return generateBusinessNo("TK", userNo); + } + + /** + * 生成支付单号 + */ + public String generatePaymentNo(String userNo) { + return generateBusinessNo("PY", userNo); + } + + /** + * 统一生成业务单号 + */ + private String generateBusinessNo(String prefix, String userNo) { + // 时间部分:yyyyMMddHHmmss + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + + // userNo 后6位(用于路由) + String userSuffix = extractUserSuffix(userNo); + + // 序列号(3位)- 使用 Redis 保证唯一性 + String sequence = String.format("%03d", getSequence(prefix + timestamp)); + + return prefix + timestamp + userSuffix + sequence; + } + + /** + * 提取 userNo 后6位 + */ + private String extractUserSuffix(String userNo) { + if (userNo.length() <= 6) { + return String.format("%06d", Long.parseLong(userNo)); + } + return userNo.substring(userNo.length() - 6); + } + + /** + * 获取序列号(Redis 自增) + */ + private Long getSequence(String key) { + Long seq = redisTemplate.opsForValue().increment("seq:" + key); + return seq % 1000; // 3位序列号,循环使用 + } + + /** + * 从业务单号解析 userNo 后缀 + */ + public String parseUserSuffix(String businessNo) { + // OD20240309123456789012001 + // TK20240309123456789012001 + // PY20240309123456789012001 + // 从第16位开始,取6位 + if (businessNo.length() >= 22) { + return businessNo.substring(16, 22); + } + return null; + } + + /** + * 根据业务单号计算分片信息 + */ + public ShardingInfo getShardingInfo(String businessNo) { + String userSuffix = parseUserSuffix(businessNo); + if (userSuffix == null) { + throw new IllegalArgumentException("Invalid businessNo: " + businessNo); + } + + long userNoSuffix = Long.parseLong(userSuffix); + int dbIndex = (int) (userNoSuffix % 16); + int tableIndex = (int) ((userNoSuffix / 16) % 16); + + return new ShardingInfo(dbIndex, tableIndex); + } + + /** + * 判断业务单号类型 + */ + public BusinessNoType getBusinessNoType(String businessNo) { + if (businessNo.startsWith("OD")) { + return BusinessNoType.ORDER; + } else if (businessNo.startsWith("TK")) { + return BusinessNoType.TICKET; + } else if (businessNo.startsWith("PY")) { + return BusinessNoType.PAYMENT; + } + throw new IllegalArgumentException("Unknown businessNo type: " + businessNo); + } +} + +/** + * 业务单号类型枚举 + */ +public enum BusinessNoType { + ORDER, // 订单 + TICKET, // 凭证 + PAYMENT // 支付单 +} + +/** + * 分片信息 + */ +@Data +@AllArgsConstructor +public class ShardingInfo { + private int dbIndex; + private int tableIndex; + + public String getDbName() { + return "ds" + dbIndex; + } + + public String getTableSuffix() { + return String.valueOf(tableIndex); + } +} +``` + +--- + +## 查询实现 + +### 1. 订单列表查询(带 userNo) + +```java +@Service +public class OrderQueryService { + + /** + * 查询用户订单列表 + */ + public PageResult queryOrderList(OrderQueryDTO query) { + // 参数:userNo, startTime, endTime, orderType, orderStatus + + // 1. 根据 userNo 路由到具体分片 + ShardingInfo sharding = calculateSharding(query.getUserNo()); + + // 2. 构建查询条件 + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_no", query.getUserNo()); + + if (query.getStartTime() != null) { + wrapper.ge("create_time", query.getStartTime()); + } + if (query.getEndTime() != null) { + wrapper.le("create_time", query.getEndTime()); + } + if (query.getOrderType() != null) { + wrapper.eq("order_type", query.getOrderType()); + } + if (query.getOrderStatus() != null) { + wrapper.eq("order_status", query.getOrderStatus()); + } + + wrapper.orderByDesc("create_time"); + + // 3. 分页查询 + Page page = new Page<>(query.getPageNo(), query.getPageSize()); + Page result = orderMapper.selectPage(page, wrapper); + + return PageResult.of(result); + } +} +``` + +**SQL 示例:** +```sql +-- 路由到 order_db_0.order_8 +SELECT * FROM order_8 +WHERE user_no = '123456' + AND create_time >= '2024-01-01' + AND create_time <= '2024-03-09' + AND order_type = 1 + AND order_status = 2 +ORDER BY create_time DESC +LIMIT 0, 20; + +-- 使用索引:idx_user_type (user_no, order_type, create_time) +``` + +### 2. 订单详情查询(只有 orderNo) + +```java +@Service +public class OrderQueryService { + + /** + * 根据订单号查询详情 + */ + public Order getOrderDetail(String orderNo) { + // 方案一:从订单号解析分片信息(推荐) + ShardingInfo sharding = orderNoGenerator.getShardingInfo(orderNo); + + // 方案二:查询路由表 + // OrderRoute route = orderRouteRepository.findByOrderNo(orderNo); + // ShardingInfo sharding = new ShardingInfo(route.getDbIndex(), route.getTableIndex()); + + // 查询订单 + return orderMapper.selectByOrderNo(sharding, orderNo); + } +} +``` + +**SQL 示例:** +```sql +-- 路由到 order_db_0.order_8 +SELECT * FROM order_8 +WHERE order_no = 'OD20240309123456789012001'; + +-- 使用索引:uk_order_no +``` + +### 3. 凭证查询 + +```java +@Service +public class TicketQueryService { + + @Autowired + private BusinessNoGenerator businessNoGenerator; + + @Autowired + private TicketMapper ticketMapper; + + /** + * 根据凭证号查询凭证详情(核心场景) + */ + public Ticket getTicketByTicketNo(String ticketNo) { + // 从凭证号直接解析分片信息 + ShardingInfo sharding = businessNoGenerator.getShardingInfo(ticketNo); + + // 查询凭证 + return ticketMapper.selectByTicketNo(sharding, ticketNo); + } + + /** + * 根据订单号查询凭证列表 + */ + public List getTicketsByOrderNo(String orderNo) { + // 从订单号解析分片信息 + ShardingInfo sharding = businessNoGenerator.getShardingInfo(orderNo); + + // 查询凭证(凭证和订单在同一分片) + return ticketMapper.selectByOrderNo(sharding, orderNo); + } + + /** + * 查询用户凭证列表 + */ + public PageResult getUserTickets(TicketQueryDTO query) { + // 根据 userNo 路由 + ShardingInfo sharding = calculateSharding(query.getUserNo()); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_no", query.getUserNo()); + + if (query.getStatus() != null) { + wrapper.eq("ticket_status", query.getStatus()); + } + if (query.getStartTime() != null) { + wrapper.ge("create_time", query.getStartTime()); + } + if (query.getEndTime() != null) { + wrapper.le("create_time", query.getEndTime()); + } + + wrapper.orderByDesc("create_time"); + + Page page = new Page<>(query.getPageNo(), query.getPageSize()); + Page result = ticketMapper.selectPage(page, wrapper); + + return PageResult.of(result); + } + + /** + * 凭证核销(扫码场景) + */ + public Result verifyTicket(String ticketNo) { + // 1. 从凭证号解析分片 + ShardingInfo sharding = businessNoGenerator.getShardingInfo(ticketNo); + + // 2. 查询凭证 + Ticket ticket = ticketMapper.selectByTicketNo(sharding, ticketNo); + + if (ticket == null) { + return Result.fail("TICKET_NOT_FOUND", "凭证不存在"); + } + + if (ticket.getTicketStatus() != TicketStatus.UNUSED.getCode()) { + return Result.fail("TICKET_INVALID", "凭证状态异常"); + } + + // 3. 更新凭证状态 + ticket.setTicketStatus(TicketStatus.USED.getCode()); + ticket.setUseTime(LocalDateTime.now()); + ticketMapper.updateById(ticket); + + return Result.success(true); + } +} +``` + +**SQL 示例:** + +```sql +-- 1. 根据凭证号查询(从凭证号解析到 ticket_db_5.ticket_12) +SELECT * FROM ticket_12 +WHERE ticket_no = 'TK20240309123456789012001'; +-- 使用索引:uk_ticket_no +-- 执行时间:<5ms + +-- 2. 根据订单号查询凭证(从订单号解析到同一分片) +SELECT * FROM ticket_12 +WHERE order_no = 'OD20240309123456789012001'; +-- 使用索引:idx_order_no +-- 执行时间:<10ms + +-- 3. 用户凭证列表 +SELECT * FROM ticket_12 +WHERE user_no = '123456' + AND ticket_status = 1 + AND create_time >= '2024-01-01' +ORDER BY create_time DESC +LIMIT 0, 20; +-- 使用索引:idx_user_status +-- 执行时间:<30ms +``` + +### 4. 支付单查询 + +```java +@Service +public class PaymentQueryService { + + @Autowired + private BusinessNoGenerator businessNoGenerator; + + @Autowired + private PaymentMapper paymentMapper; + + @Autowired + private PaymentRouteRepository paymentRouteRepository; + + /** + * 根据支付单号查询(核心场景) + */ + public Payment getPaymentByPaymentNo(String paymentNo) { + // 从支付单号直接解析分片信息 + ShardingInfo sharding = businessNoGenerator.getShardingInfo(paymentNo); + + return paymentMapper.selectByPaymentNo(sharding, paymentNo); + } + + /** + * 根据订单号查询支付单列表 + */ + public List getPaymentsByOrderNo(String orderNo) { + // 从订单号解析分片信息 + ShardingInfo sharding = businessNoGenerator.getShardingInfo(orderNo); + + return paymentMapper.selectByOrderNo(sharding, orderNo); + } + + /** + * 查询用户支付单列表 + */ + public PageResult getUserPayments(PaymentQueryDTO query) { + // 根据 userNo 路由 + ShardingInfo sharding = calculateSharding(query.getUserNo()); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_no", query.getUserNo()); + + if (query.getPaymentType() != null) { + wrapper.eq("payment_type", query.getPaymentType()); + } + if (query.getPaymentStatus() != null) { + wrapper.eq("payment_status", query.getPaymentStatus()); + } + if (query.getStartTime() != null) { + wrapper.ge("create_time", query.getStartTime()); + } + if (query.getEndTime() != null) { + wrapper.le("create_time", query.getEndTime()); + } + + wrapper.orderByDesc("create_time"); + + Page page = new Page<>(query.getPageNo(), query.getPageSize()); + Page result = paymentMapper.selectPage(page, wrapper); + + return PageResult.of(result); + } + + /** + * 第三方支付回调查询(特殊场景) + * + * 问题:第三方支付流水号不包含 userNo 信息,无法直接路由 + * 解决方案:使用路由表 + */ + public Payment getPaymentByThirdPartyNo(String thirdPartyNo) { + // 方案一:查询路由表(推荐) + PaymentRoute route = paymentRouteRepository.findByThirdPartyNo(thirdPartyNo); + + if (route == null) { + return null; + } + + ShardingInfo sharding = new ShardingInfo(route.getDbIndex(), route.getTableIndex()); + return paymentMapper.selectByPaymentNo(sharding, route.getPaymentNo()); + } + + /** + * 创建支付单时,同步写入路由表 + */ + @Transactional + public void createPayment(Payment payment) { + // 1. 写入支付单 + paymentMapper.insert(payment); + + // 2. 写入路由表(用于第三方回调查询) + if (StringUtils.isNotBlank(payment.getThirdPartyNo())) { + PaymentRoute route = new PaymentRoute(); + route.setPaymentNo(payment.getPaymentNo()); + route.setThirdPartyNo(payment.getThirdPartyNo()); + route.setUserNo(payment.getUserNo()); + + ShardingInfo sharding = businessNoGenerator.getShardingInfo(payment.getPaymentNo()); + route.setDbIndex(sharding.getDbIndex()); + route.setTableIndex(sharding.getTableIndex()); + route.setCreateTime(LocalDateTime.now()); + + paymentRouteRepository.save(route); + } + } +} +``` + +**支付单路由表设计:** + +```sql +-- 支付单路由表(不分表,或按 third_party_no 分表) +CREATE TABLE `payment_route` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `payment_no` VARCHAR(64) NOT NULL COMMENT '支付单编号', + `third_party_no` VARCHAR(128) 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_third_party_no` (`third_party_no`), + KEY `idx_payment_no` (`payment_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付单路由表'; +``` + +**SQL 示例:** + +```sql +-- 1. 根据支付单号查询(从支付单号解析到 payment_db_3.payment_7) +SELECT * FROM payment_7 +WHERE payment_no = 'PY20240309123456789012001'; +-- 使用索引:uk_payment_no +-- 执行时间:<5ms + +-- 2. 根据订单号查询支付单(从订单号解析到同一分片) +SELECT * FROM payment_7 +WHERE order_no = 'OD20240309123456789012001'; +-- 使用索引:idx_order_no +-- 执行时间:<10ms + +-- 3. 第三方回调查询(先查路由表) +-- Step 1: 查询路由表 +SELECT payment_no, db_index, table_index +FROM payment_route +WHERE third_party_no = '2024030912345678901234567890'; +-- 执行时间:<5ms + +-- Step 2: 根据路由信息查询支付单 +SELECT * FROM payment_7 +WHERE payment_no = 'PY20240309123456789012001'; +-- 执行时间:<5ms + +-- 总耗时:<10ms + +-- 4. 用户支付单列表 +SELECT * FROM payment_7 +WHERE user_no = '123456' + AND payment_type = 1 + AND payment_status = 3 + AND create_time >= '2024-01-01' +ORDER BY create_time DESC +LIMIT 0, 20; +-- 使用索引:idx_user_type_status +-- 执行时间:<30ms +``` + +--- + +## ShardingSphere 配置 + +### 1. 数据源配置 + +```yaml +spring: + shardingsphere: + datasource: + names: ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9,ds10,ds11,ds12,ds13,ds14,ds15 + + # 配置16个数据源 + ds0: + type: com.zaxxer.hikari.HikariDataSource + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: jdbc:mysql://localhost:3306/order_db_0 + username: root + password: password + + ds1: + type: com.zaxxer.hikari.HikariDataSource + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: jdbc:mysql://localhost:3306/order_db_1 + username: root + password: password + + # ... ds2 ~ ds15 配置类似 + + rules: + sharding: + # 分片算法 + sharding-algorithms: + database-inline: + type: INLINE + props: + algorithm-expression: ds$->{user_no.toLong() % 16} + + table-inline: + type: INLINE + props: + algorithm-expression: order_$->{(user_no.toLong() / 16) % 16} + + # 订单表分片规则 + tables: + order: + actual-data-nodes: ds$->{0..15}.order_$->{0..15} + database-strategy: + standard: + sharding-column: user_no + sharding-algorithm-name: database-inline + table-strategy: + standard: + sharding-column: user_no + sharding-algorithm-name: table-inline + key-generate-strategy: + column: id + key-generator-name: snowflake + + # 凭证表分片规则 + ticket: + actual-data-nodes: ds$->{0..15}.ticket_$->{0..15} + database-strategy: + standard: + sharding-column: user_no + sharding-algorithm-name: database-inline + table-strategy: + standard: + sharding-column: user_no + sharding-algorithm-name: table-inline + + # 支付单表分片规则 + payment: + actual-data-nodes: ds$->{0..15}.payment_$->{0..15} + database-strategy: + standard: + sharding-column: user_no + sharding-algorithm-name: database-inline + table-strategy: + standard: + sharding-column: user_no + sharding-algorithm-name: table-inline + + # 绑定表(关联查询优化) + binding-tables: + - order,ticket,payment + + # 广播表(不分片的表) + broadcast-tables: + - order_route + + props: + sql-show: true +``` + +### 2. 自定义分片算法(支持订单号路由) + +```java +@Component +public class OrderNoShardingAlgorithm implements StandardShardingAlgorithm { + + @Autowired + private OrderNoGenerator orderNoGenerator; + + @Override + public String doSharding(Collection availableTargetNames, + PreciseShardingValue shardingValue) { + String columnValue = shardingValue.getValue(); + + // 判断是 userNo 还是 orderNo + if (columnValue.startsWith("OD")) { + // 订单号,解析出分片信息 + ShardingInfo sharding = orderNoGenerator.getShardingInfo(columnValue); + + if ("user_no".equals(shardingValue.getColumnName())) { + return "ds" + sharding.getDbIndex(); + } else { + return "order_" + sharding.getTableIndex(); + } + } else { + // userNo,直接计算 + long userNo = Long.parseLong(columnValue); + int index = (int) (userNo % 16); + + if ("user_no".equals(shardingValue.getColumnName())) { + return "ds" + index; + } else { + return "order_" + ((int) ((userNo / 16) % 16)); + } + } + } + + @Override + public Collection doSharding(Collection availableTargetNames, + RangeShardingValue shardingValue) { + // 范围查询,返回所有分片 + return availableTargetNames; + } +} +``` + +--- + +## 性能优化建议 + +### 1. 索引优化 + +```sql +-- 订单表核心索引 +ALTER TABLE order_0 ADD INDEX idx_user_create (user_no, create_time); +ALTER TABLE order_0 ADD INDEX idx_user_status (user_no, order_status, create_time); +ALTER TABLE order_0 ADD INDEX idx_user_type (user_no, order_type, create_time); + +-- 联合索引覆盖查询 +ALTER TABLE order_0 ADD INDEX idx_user_list ( + user_no, order_type, order_status, create_time, order_no, total_amount +); +``` + +### 2. 分区表(单表内再分区) + +```sql +-- 按月分区 +CREATE TABLE `order_0` ( + ... +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 +PARTITION BY RANGE (TO_DAYS(create_time)) ( + PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')), + PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')), + PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')), + ... +); +``` + +### 3. 冷热数据分离 + +```sql +-- 热数据表(近3个月) +CREATE TABLE order_hot_0 LIKE order_0; + +-- 冷数据表(3个月以上) +CREATE TABLE order_cold_0 LIKE order_0; + +-- 定时任务迁移冷数据 +``` + +### 4. 读写分离 + +```yaml +spring: + shardingsphere: + rules: + readwrite-splitting: + data-sources: + ds0: + write-data-source-name: ds0-master + read-data-source-names: + - ds0-slave1 + - ds0-slave2 + load-balancer-name: round-robin +``` + +--- + +## 总结 + +### 推荐方案 + +**订单号包含 userNo 信息 + 按 userNo 分库分表** + +**优点:** +- 所有查询都能精确路由到单个分片 +- 无需额外的路由表 +- 性能最优 +- 实现简单 + +**分片规则:** +- 16个库 × 16张表 = 256个分片 +- 单分片数据量:10亿 / 256 ≈ 400万(可控) + +**查询性能:** +- 订单列表(带 userNo):单分片查询,<50ms +- 订单详情(orderNo):单分片查询,<10ms +- 凭证查询:单分片查询,<20ms +- 支付单查询:单分片查询,<20ms + +这个方案在大数据量场景下能保证高性能和可扩展性。