引言:
- 本文總字數:約 8500 字
- 建議閱讀時間:35 分鐘
當訂單表撐爆數據庫,我們該怎么辦?
想象一下,你負責的電商平臺在經歷了幾個雙十一后,訂單系統開始頻繁出現問題:數據庫查詢越來越慢,甚至在高峰期出現超時;運維團隊每天都在抱怨數據庫服務器負載過高;營銷部門的數據分析報告總是延遲,因為全表掃描需要數小時。
這不是危言聳聽,而是每個快速發展的業務都會面臨的真實挑戰。根據 MySQL 官方文檔(https://dev.mysql.com/doc/refman/8.0/en/table-size-limit.html),單表數據量超過 1000 萬行時,性能會顯著下降。而對于訂單表這種寫入密集、查詢頻繁的核心業務表,這個閾值可能還要低得多。
分表分庫(Sharding)技術正是解決這個問題的關鍵。它通過將一個大表的數據分散到多個小表、多個數據庫中,從而提升系統的并發能力和查詢效率。本文將從理論到實踐,全方位解析訂單表分表分庫的設計與實現,讓你不僅能理解其底層邏輯,更能直接應用到實際項目中。
一、分表分庫核心概念:你必須理解的基礎知識
1.1 什么是分表分庫?
分表分庫是一種數據庫水平擴展技術,主要包括兩種方式:
- 分表(Table Sharding):將一個大表按照某種規則拆分成多個小表,這些小表可以在同一個數據庫中,也可以分布在不同的數據庫中。
- 分庫(Database Sharding):將一個數據庫按照某種規則拆分成多個數據庫,每個數據庫可以部署在不同的服務器上。
用一個形象的比喻:如果把數據庫比作倉庫,表比作貨架,那么分表就像是把一個長貨架拆分成多個短貨架,分庫則是把一個大倉庫分成多個小倉庫。
1.2 垂直拆分 vs 水平拆分
分表分庫可以分為垂直和水平兩種拆分策略:
-
垂直拆分:按照業務或數據的重要性進行拆分
- 垂直分庫:將不同業務模塊的數據拆分到不同的數據庫,如訂單庫、用戶庫、商品庫
- 垂直分表:將一個表中不常用的字段拆分到另一個表,如訂單表中的基本信息和詳細信息分離
-
水平拆分:按照某種規則將同一業務的數據分散存儲
- 水平分庫:將同一表的數據拆分到多個數據庫
- 水平分表:將同一表的數據拆分到同一數據庫的多個表中
對于訂單表,我們通常采用水平拆分,因為訂單數據具有天然的可拆分性,且隨著業務增長,數據量會持續增加。
1.3 分表分庫的優勢與挑戰
優勢:
- 提升查詢性能:小表的查詢速度遠快于大表
- 提高并發能力:分散到多個數據庫,可同時處理更多請求
- 便于擴容:可以按需增加數據庫服務器
- 提高可用性:單個庫表故障不會導致整個系統不可用
挑戰:
- 分布式事務:跨庫操作需要特殊處理
- 跨庫查詢:JOIN 操作變得復雜
- 數據遷移:擴容時需要遷移數據
- 全局 ID:需要生成唯一的全局標識符
- 運維復雜度:管理多個庫表增加了運維難度
二、訂單表分表分庫設計:從理論到方案
2.1 拆分策略選擇:為什么訂單表適合按時間和用戶 ID 拆分?
訂單表的拆分策略需要結合業務查詢模式。常見的拆分鍵(Sharding Key)選擇有:
- 按用戶 ID 拆分:適合需要查詢某個用戶所有訂單的場景
- 按訂單創建時間拆分:適合按時間范圍查詢的場景,如月度報表
- 按訂單 ID 哈希拆分:數據分布均勻,但時間范圍查詢困難
對于大多數電商平臺,我們推薦復合策略:先按時間范圍(如月份)拆分,再在每個時間范圍內按用戶 ID 哈希拆分。這樣既滿足了按用戶查詢的需求,也方便了按時間歸檔數據。
阿里巴巴《Java 開發手冊(嵩山版)》中明確建議:"分庫分表時,拆分字段的選擇至關重要,需要結合業務查詢場景,盡量避免跨庫跨表查詢。"
2.2 分表分庫粒度:多少數據量一個表合適?
單表數據量的閾值需要根據業務場景和硬件配置來確定,通常有以下參考:
- 并發查詢較多的表:建議單表數據量控制在 500 萬以內
- 以插入和簡單查詢為主的表:可以放寬到 1000 萬 - 2000 萬
訂單表作為核心業務表,查詢復雜且頻繁,建議單表數據量控制在 500 萬以內。根據預估的年訂單量,可以計算出需要的表數量:
例如,若預計年訂單量為 1 億,單表 500 萬,則每年需要 20 個表。如果按月份拆分,每月大約需要 2 個表,這意味著每個月內還需要再按用戶 ID 進一步拆分。
2.3 數據庫和表的命名規范
清晰的命名規范有助于維護和排查問題,建議如下:
- 分庫命名:
order_db_${時間標識}_${分片序號}
- 示例:
order_db_202310_00
(2023 年 10 月的第 00 號訂單庫)
- 示例:
- 分表命名:
order_tbl_${時間標識}_${分片序號}
- 示例:
order_tbl_202310_01
(2023 年 10 月的第 01 號訂單表)
- 示例:
時間標識可以是年份(如 2023)、年份 + 季度(如 2023Q4)或年份 + 月份(如 202310),根據數據量和查詢頻率選擇合適的粒度。
2.4 全局 ID 生成策略
分表分庫后,傳統的自增 ID 無法保證全局唯一,需要全局 ID 生成策略:
- UUID/GUID:優點是簡單,缺點是無序、占空間大
- 雪花算法(Snowflake):64 位 ID,包含時間戳、機器 ID 等,有序且唯一
- 數據庫自增 ID 表:單獨的數據庫表生成 ID,可能成為瓶頸
- Redis 自增:利用 INCR 命令,性能好但依賴 Redis
對于訂單 ID,推薦使用雪花算法,因為它生成的 ID 有序,有利于索引性能,且包含時間信息,便于定位數據所在的分片。
三、分表分庫中間件選型:ShardingSphere 實戰
3.1 主流分表分庫中間件對比
目前主流的分表分庫中間件有:
中間件 | 優點 | 缺點 | 適用場景 |
---|---|---|---|
ShardingSphere | 功能全面,支持多種數據庫,社區活躍 | 配置復雜 | 大多數企業級應用 |
MyCat | 基于 MySQL 協議,透明接入 | 對新特性支持較慢 | 以 MySQL 為主的應用 |
DRDS | 阿里云產品,運維簡單 | 商業化,成本高 | 阿里云生態用戶 |
Apache ShardingSphere(Apache ShardingSphere)是目前最受歡迎的開源分表分庫解決方案,它包含 JDBC、Proxy 和 Sidecar 三個產品,本文將以 ShardingSphere-JDBC 為例進行講解。
3.2 ShardingSphere 核心概念
- 邏輯表:拆分前的原表,如
order_tbl
- 實際表:拆分后的物理表,如
order_tbl_202310_00
- 數據節點:由數據源和實際表組成,如
order_db_202310_00.order_tbl_202310_00
- 分片鍵:用于拆分的字段,如
user_id
、create_time
- 分片策略:如何將數據分配到不同的分片,包括分片算法和分片規則
3.3 項目環境搭建
3.3.1 Maven 依賴配置
首先,我們需要在pom.xml
中添加必要的依賴:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.2.0</version><relativePath/></parent><groupId>com.jam.order</groupId><artifactId>order-sharding</artifactId><version>1.0.0</version><properties><java.version>17</java.version><shardingsphere.version>5.4.0</shardingsphere.version><mybatis-plus.version>3.5.5</mybatis-plus.version><lombok.version>1.18.30</lombok.version><commons-lang3.version>3.14.0</commons-lang3.version><springdoc.version>2.1.0</springdoc.version></properties><dependencies><!-- Spring Boot 核心 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><!-- 數據庫 --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><!-- ShardingSphere --><dependency><groupId>org.apache.shardingsphere</groupId><artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId><version>${shardingsphere.version}</version></dependency><!-- MyBatis-Plus --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>${mybatis-plus.version}</version></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version><scope>provided</scope></dependency><!-- 工具類 --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>${commons-lang3.version}</version></dependency><!-- Swagger3 --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency><!-- 測試 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build>
</project>
3.3.2 數據庫初始化腳本
我們需要創建分庫分表的數據庫和表結構。這里以按月分庫,每個庫按用戶 ID 哈希分為 4 個表為例:
-- 創建2023年10月的訂單庫
CREATE DATABASE IF NOT EXISTS order_db_202310_00 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS order_db_202310_01 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS order_db_202310_02 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS order_db_202310_03 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;-- 在每個庫中創建4個訂單表
DELIMITER $$
CREATE PROCEDURE create_order_tables(IN db_suffix CHAR(2))
BEGINDECLARE i INT DEFAULT 0;WHILE i < 4 DOSET @sql = CONCAT('CREATE TABLE IF NOT EXISTS order_db_202310_', db_suffix, '.order_tbl_202310_', i, ' (id BIGINT NOT NULL COMMENT \'訂單ID\',user_id BIGINT NOT NULL COMMENT \'用戶ID\',order_no VARCHAR(64) NOT NULL COMMENT \'訂單編號\',total_amount DECIMAL(10,2) NOT NULL COMMENT \'訂單總金額\',pay_amount DECIMAL(10,2) NOT NULL COMMENT \'實付金額\',freight DECIMAL(10,2) NOT NULL COMMENT \'運費\',order_status TINYINT NOT NULL COMMENT \'訂單狀態:0-待付款,1-待發貨,2-已發貨,3-已完成,4-已取消\',payment_time DATETIME COMMENT \'支付時間\',delivery_time DATETIME COMMENT \'發貨時間\',receive_time DATETIME COMMENT \'確認收貨時間\',comment_time DATETIME COMMENT \'評價時間\',create_time DATETIME NOT NULL COMMENT \'創建時間\',update_time DATETIME NOT NULL COMMENT \'更新時間\',PRIMARY KEY (id),KEY idx_user_id (user_id),KEY idx_create_time (create_time)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT=\'訂單表\'');PREPARE stmt FROM @sql;EXECUTE stmt;DEALLOCATE PREPARE stmt;SET i = i + 1;END WHILE;
END$$
DELIMITER ;-- 調用存儲過程創建表
CALL create_order_tables('00');
CALL create_order_tables('01');
CALL create_order_tables('02');
CALL create_order_tables('03');-- 創建訂單_item表(略,結構類似)
3.4 ShardingSphere 配置
下面是application.yml
的配置,實現按時間(月份)分庫,按用戶 ID 哈希分表:
spring:shardingsphere:datasource:names: db-202310-00, db-202310-01, db-202310-02, db-202310-03db-202310-00:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverjdbc-url: jdbc:mysql://localhost:3306/order_db_202310_00?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: rootdb-202310-01:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverjdbc-url: jdbc:mysql://localhost:3306/order_db_202310_01?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: rootdb-202310-02:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverjdbc-url: jdbc:mysql://localhost:3306/order_db_202310_02?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: rootdb-202310-03:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverjdbc-url: jdbc:mysql://localhost:3306/order_db_202310_03?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghaiusername: rootpassword: rootrules:sharding:tables:order_tbl:actual-data-nodes: db-202310-${0..3}.order_tbl_202310-${0..3}database-strategy:standard:sharding-column: create_timesharding-algorithm-name: order-db-inlinetable-strategy:standard:sharding-column: user_idsharding-algorithm-name: order-tbl-inlinekey-generate-strategy:column: idkey-generator-name: snowflakesharding-algorithms:order-db-inline:type: INLINEprops:algorithm-expression: db-202310-${create_time.toString('yyyyMM') % 4}order-tbl-inline:type: INLINEprops:algorithm-expression: order_tbl_202310-${user_id % 4}key-generators:snowflake:type: SNOWFLAKEprops:worker-id: 1data-center-id: 1props:sql-show: truequery-with-cipher-column: truemybatis-plus:mapper-locations: classpath*:mapper/**/*.xmlglobal-config:db-config:id-type: ASSIGN_IDlogic-delete-field: deletedlogic-delete-value: 1logic-not-delete-value: 0configuration:map-underscore-to-camel-case: truelog-impl: org.apache.ibatis.logging.stdout.StdOutImplspringdoc:api-docs:path: /api-docsswagger-ui:path: /swagger-ui.htmloperationsSorter: methodpackages-to-scan: com.jam.order.controller
四、訂單表分表分庫核心代碼實現
4.1 實體類設計
package com.jam.order.entity;import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;import java.math.BigDecimal;
import java.time.LocalDateTime;/*** 訂單實體類** @author 果醬*/
@Data
@TableName("order_tbl")
@Schema(description = "訂單信息")
public class Order {/*** 訂單ID*/@TableId(type = IdType.ASSIGN_ID)@Schema(description = "訂單ID")private Long id;/*** 用戶ID*/@Schema(description = "用戶ID")private Long userId;/*** 訂單編號*/@Schema(description = "訂單編號")private String orderNo;/*** 訂單總金額*/@Schema(description = "訂單總金額")private BigDecimal totalAmount;/*** 實付金額*/@Schema(description = "實付金額")private BigDecimal payAmount;/*** 運費*/@Schema(description = "運費")private BigDecimal freight;/*** 訂單狀態:0-待付款,1-待發貨,2-已發貨,3-已完成,4-已取消*/@Schema(description = "訂單狀態:0-待付款,1-待發貨,2-已發貨,3-已完成,4-已取消")private Integer orderStatus;/*** 支付時間*/@Schema(description = "支付時間")private LocalDateTime paymentTime;/*** 發貨時間*/@Schema(description = "發貨時間")private LocalDateTime deliveryTime;/*** 確認收貨時間*/@Schema(description = "確認收貨時間")private LocalDateTime receiveTime;/*** 評價時間*/@Schema(description = "評價時間")private LocalDateTime commentTime;/*** 創建時間*/@Schema(description = "創建時間")private LocalDateTime createTime;/*** 更新時間*/@Schema(description = "更新時間")private LocalDateTime updateTime;
}
4.2 Mapper 接口
package com.jam.order.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.order.entity.Order;
import org.apache.ibatis.annotations.Param;import java.time.LocalDateTime;
import java.util.List;/*** 訂單Mapper接口** @author 果醬*/
public interface OrderMapper extends BaseMapper<Order> {/*** 根據用戶ID查詢訂單列表** @param userId 用戶ID* @param startTime 開始時間* @param endTime 結束時間* @return 訂單列表*/List<Order> selectByUserIdAndTimeRange(@Param("userId") Long userId,@Param("startTime") LocalDateTime startTime,@Param("endTime") LocalDateTime endTime);/*** 根據訂單狀態查詢訂單數量** @param orderStatus 訂單狀態* @param startTime 開始時間* @param endTime 結束時間* @return 訂單數量*/Long countByStatusAndTimeRange(@Param("orderStatus") Integer orderStatus,@Param("startTime") LocalDateTime startTime,@Param("endTime") LocalDateTime endTime);
}
4.3 Service 層實現
package com.jam.order.service;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.order.entity.Order;
import com.jam.order.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;/*** 訂單服務實現類** @author 果醬*/
@Slf4j
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {/*** 創建訂單** @param order 訂單信息* @return 訂單ID*/@Override@Transactional(rollbackFor = Exception.class)public Long createOrder(Order order) {// 參數校驗Objects.requireNonNull(order, "訂單信息不能為空");Objects.requireNonNull(order.getUserId(), "用戶ID不能為空");StringUtils.hasText(order.getOrderNo(), "訂單編號不能為空");Objects.requireNonNull(order.getTotalAmount(), "訂單總金額不能為空");// 設置默認值LocalDateTime now = LocalDateTime.now();order.setCreateTime(now);order.setUpdateTime(now);order.setOrderStatus(0); // 默認為待付款狀態// 保存訂單boolean saveResult = save(order);if (!saveResult) {log.error("創建訂單失敗,訂單信息:{}", order);throw new RuntimeException("創建訂單失敗");}log.info("創建訂單成功,訂單ID:{},訂單編號:{}", order.getId(), order.getOrderNo());return order.getId();}/*** 根據用戶ID查詢訂單列表** @param userId 用戶ID* @param startTime 開始時間* @param endTime 結束時間* @return 訂單列表*/@Overridepublic List<Order> getOrdersByUserId(Long userId, LocalDateTime startTime, LocalDateTime endTime) {Objects.requireNonNull(userId, "用戶ID不能為空");Objects.requireNonNull(startTime, "開始時間不能為空");Objects.requireNonNull(endTime, "結束時間不能為空");log.info("查詢用戶訂單,用戶ID:{},時間范圍:{}至{}", userId, startTime, endTime);return baseMapper.selectByUserIdAndTimeRange(userId, startTime, endTime);}/*** 更新訂單狀態** @param orderId 訂單ID* @param orderStatus 訂單狀態* @return 是否更新成功*/@Override@Transactional(rollbackFor = Exception.class)public boolean updateOrderStatus(Long orderId, Integer orderStatus) {Objects.requireNonNull(orderId, "訂單ID不能為空");Objects.requireNonNull(orderStatus, "訂單狀態不能為空");Order order = new Order();order.setId(orderId);order.setOrderStatus(orderStatus);order.setUpdateTime(LocalDateTime.now());// 根據狀態更新對應的時間switch (orderStatus) {case 1: // 待發貨,說明已支付order.setPaymentTime(LocalDateTime.now());break;case 2: // 已發貨order.setDeliveryTime(LocalDateTime.now());break;case 3: // 已完成order.setReceiveTime(LocalDateTime.now());break;case 4: // 已取消break;default:log.error("不支持的訂單狀態:{}", orderStatus);throw new IllegalArgumentException("不支持的訂單狀態");}boolean updateResult = updateById(order);log.info("更新訂單狀態,訂單ID:{},新狀態:{},結果:{}", orderId, orderStatus, updateResult);return updateResult;}/*** 統計指定狀態的訂單數量** @param orderStatus 訂單狀態* @param startTime 開始時間* @param endTime 結束時間* @return 訂單數量*/@Overridepublic Long countOrdersByStatus(Integer orderStatus, LocalDateTime startTime, LocalDateTime endTime) {Objects.requireNonNull(orderStatus, "訂單狀態不能為空");Objects.requireNonNull(startTime, "開始時間不能為空");Objects.requireNonNull(endTime, "結束時間不能為空");log.info("統計訂單數量,狀態:{},時間范圍:{}至{}", orderStatus, startTime, endTime);return baseMapper.countByStatusAndTimeRange(orderStatus, startTime, endTime);}
}
4.4 Controller 層實現
package com.jam.order.controller;import com.jam.order.entity.Order;
import com.jam.order.service.OrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;/*** 訂單控制器** @author 果醬*/
@Slf4j
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "訂單管理", description = "訂單相關的CRUD操作")
public class OrderController {private final OrderService orderService;public OrderController(OrderService orderService) {this.orderService = orderService;}/*** 創建訂單** @param order 訂單信息* @return 訂單ID*/@PostMapping@Operation(summary = "創建訂單", description = "創建新的訂單")public ResponseEntity<Long> createOrder(@RequestBody Order order) {Long orderId = orderService.createOrder(order);return ResponseEntity.ok(orderId);}/*** 查詢訂單詳情** @param orderId 訂單ID* @return 訂單詳情*/@GetMapping("/{orderId}")@Operation(summary = "查詢訂單詳情", description = "根據訂單ID查詢訂單詳情")public ResponseEntity<Order> getOrderDetail(@Parameter(description = "訂單ID", required = true)@PathVariable Long orderId) {Order order = orderService.getById(orderId);return ResponseEntity.ok(order);}/*** 根據用戶ID查詢訂單列表** @param userId 用戶ID* @param startTime 開始時間* @param endTime 結束時間* @return 訂單列表*/@GetMapping("/user/{userId}")@Operation(summary = "查詢用戶訂單", description = "根據用戶ID和時間范圍查詢訂單列表")public ResponseEntity<List<Order>> getOrdersByUserId(@Parameter(description = "用戶ID", required = true)@PathVariable Long userId,@Parameter(description = "開始時間", required = true)@RequestParam LocalDateTime startTime,@Parameter(description = "結束時間", required = true)@RequestParam LocalDateTime endTime) {List<Order> orders = orderService.getOrdersByUserId(userId, startTime, endTime);return ResponseEntity.ok(orders);}/*** 更新訂單狀態** @param orderId 訂單ID* @param orderStatus 訂單狀態* @return 是否更新成功*/@PutMapping("/{orderId}/status")@Operation(summary = "更新訂單狀態", description = "根據訂單ID更新訂單狀態")public ResponseEntity<Boolean> updateOrderStatus(@Parameter(description = "訂單ID", required = true)@PathVariable Long orderId,@Parameter(description = "訂單狀態:0-待付款,1-待發貨,2-已發貨,3-已完成,4-已取消", required = true)@RequestParam Integer orderStatus) {boolean result = orderService.updateOrderStatus(orderId, orderStatus);return ResponseEntity.ok(result);}/*** 統計指定狀態的訂單數量** @param orderStatus 訂單狀態* @param startTime 開始時間* @param endTime 結束時間* @return 訂單數量*/@GetMapping("/count")@Operation(summary = "統計訂單數量", description = "統計指定狀態和時間范圍內的訂單數量")public ResponseEntity<Long> countOrdersByStatus(@Parameter(description = "訂單狀態:0-待付款,1-待發貨,2-已發貨,3-已完成,4-已取消", required = true)@RequestParam Integer orderStatus,@Parameter(description = "開始時間", required = true)@RequestParam LocalDateTime startTime,@Parameter(description = "結束時間", required = true)@RequestParam LocalDateTime endTime) {Long count = orderService.countOrdersByStatus(orderStatus, startTime, endTime);return ResponseEntity.ok(count);}
}
4.5 自定義分片算法
上面的配置使用了 ShardingSphere 的內置 INLINE 算法,對于更復雜的場景,我們可以實現自定義分片算法:
package com.jam.order.sharding;import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.RangeShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm;import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;/*** 訂單表數據庫分片算法(按時間)** @author 果醬*/
public class OrderDatabaseShardingAlgorithm implements StandardShardingAlgorithm<LocalDateTime> {private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");private static final int DB_COUNT = 4;@Overridepublic String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<LocalDateTime> shardingValue) {LocalDateTime createTime = shardingValue.getValue();String month = createTime.format(FORMATTER);int dbIndex = Integer.parseInt(month) % DB_COUNT;String targetName = "db-" + month + "-" + String.format("%02d", dbIndex);if (availableTargetNames.contains(targetName)) {return targetName;}throw new IllegalArgumentException("未找到匹配的數據庫:" + targetName);}@Overridepublic Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<LocalDateTime> shardingValue) {// 處理范圍查詢,如between andSet<String> result = new HashSet<>();// 獲取時間范圍LocalDateTime lower = shardingValue.getValueRange().lowerEndpoint();LocalDateTime upper = shardingValue.getValueRange().upperEndpoint();// 遍歷時間范圍內的所有月份LocalDateTime current = lower;while (!current.isAfter(upper)) {String month = current.format(FORMATTER);for (int i = 0; i < DB_COUNT; i++) {String targetName = "db-" + month + "-" + String.format("%02d", i);if (availableTargetNames.contains(targetName)) {result.add(targetName);}}// 月份加1current = current.plusMonths(1);}return result;}@Overridepublic void init(Properties props) {// 初始化配置}@Overridepublic String getType() {return "ORDER_DATABASE_SHARDING";}
}
對應的表分片算法:
package com.jam.order.sharding;import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.RangeShardingValue;
import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm;import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.HashSet;
import java.util.Properties;
import java.util.Set;/*** 訂單表分片算法(按用戶ID)** @author 果醬*/
public class OrderTableShardingAlgorithm implements StandardShardingAlgorithm<Long> {private static final int TABLE_COUNT = 4;@Overridepublic String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {Long userId = shardingValue.getValue();// 獲取邏輯表名,如order_tblString logicTableName = shardingValue.getLogicTableName();// 從數據源名中提取月份信息,如db-202310-00 -> 202310String month = extractMonthFromDataSourceName(shardingValue.getDataSourceName());int tableIndex = Math.toIntExact(userId % TABLE_COUNT);String targetTableName = logicTableName + "_" + month + "_" + tableIndex;if (availableTargetNames.contains(targetTableName)) {return targetTableName;}throw new IllegalArgumentException("未找到匹配的表:" + targetTableName);}@Overridepublic Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) {// 處理范圍查詢Set<String> result = new HashSet<>();// 從數據源名中提取月份信息String month = extractMonthFromDataSourceName(shardingValue.getDataSourceName());// 對于范圍查詢,可能需要查詢所有表for (int i = 0; i < TABLE_COUNT; i++) {String targetTableName = shardingValue.getLogicTableName() + "_" + month + "_" + i;if (availableTargetNames.contains(targetTableName)) {result.add(targetTableName);}}return result;}/*** 從數據源名中提取月份信息** @param dataSourceName 數據源名,如db-202310-00* @return 月份,如202310*/private String extractMonthFromDataSourceName(String dataSourceName) {// 數據源名格式:db-202310-00String[] parts = dataSourceName.split("-");if (parts.length < 3) {throw new IllegalArgumentException("無效的數據源名:" + dataSourceName);}return parts[1];}@Overridepublic void init(Properties props) {// 初始化配置}@Overridepublic String getType() {return "ORDER_TABLE_SHARDING";}
}
然后在配置文件中使用自定義算法:
spring:shardingsphere:rules:sharding:tables:order_tbl:actual-data-nodes: db-202310-${0..3}.order_tbl_202310-${0..3}database-strategy:standard:sharding-column: create_timesharding-algorithm-name: order-db-customtable-strategy:standard:sharding-column: user_idsharding-algorithm-name: order-tbl-customkey-generate-strategy:column: idkey-generator-name: snowflakesharding-algorithms:order-db-custom:type: ORDER_DATABASE_SHARDINGprops:# 自定義屬性order-tbl-custom:type: ORDER_TABLE_SHARDINGprops:# 自定義屬性
五、分表分庫高級問題解決方案
5.1 分布式事務處理
分表分庫后,跨庫操作會導致事務問題。目前主流的分布式事務解決方案有:
- 2PC(兩階段提交):強一致性,但性能較差
- TCC(Try-Confirm-Cancel):業務侵入性強,性能好
- SAGA 模式:長事務支持好,實現復雜
- 本地消息表:可靠性高,實現簡單
- 事務消息:基于消息隊列,如 RocketMQ 的事務消息
對于訂單系統,推薦使用本地消息表或事務消息,因為它們既能保證最終一致性,又不會對性能造成太大影響。
下面是一個基于本地消息表的分布式事務示例:
package com.jam.order.service;import com.jam.order.entity.Order;
import com.jam.order.entity.OrderMessage;
import com.jam.order.enums.MessageStatus;
import com.jam.order.mapper.OrderMapper;
import com.jam.order.mapper.OrderMessageMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.time.LocalDateTime;
import java.util.UUID;/*** 基于本地消息表的分布式事務示例** @author 果醬*/
@Slf4j
@Service
public class OrderTransactionService {private final OrderMapper orderMapper;private final OrderMessageMapper orderMessageMapper;private final RabbitTemplate rabbitTemplate;public OrderTransactionService(OrderMapper orderMapper, OrderMessageMapper orderMessageMapper,RabbitTemplate rabbitTemplate) {this.orderMapper = orderMapper;this.orderMessageMapper = orderMessageMapper;this.rabbitTemplate = rabbitTemplate;}/*** 創建訂單并發送消息* 本地事務:創建訂單 + 記錄消息表*/@Transactional(rollbackFor = Exception.class)public Long createOrderWithMessage(Order order) {// 1. 創建訂單orderMapper.insert(order);// 2. 記錄消息到本地消息表OrderMessage message = new OrderMessage();message.setId(UUID.randomUUID().toString());message.setOrderId(order.getId());message.setContent("訂單創建:" + order.getOrderNo());message.setStatus(MessageStatus.PENDING);message.setCreateTime(LocalDateTime.now());message.setUpdateTime(LocalDateTime.now());orderMessageMapper.insert(message);// 3. 發送消息到MQ(非事務操作,可能失敗)try {rabbitTemplate.convertAndSend("order.exchange", "order.created", message);// 發送成功,更新消息狀態message.setStatus(MessageStatus.SENT);message.setUpdateTime(LocalDateTime.now());orderMessageMapper.updateById(message);} catch (Exception e) {log.error("發送消息失敗", e);// 發送失敗,消息狀態還是PENDING,由定時任務重試}return order.getId();}/*** 定時任務重試發送失敗的消息*/@Transactional(rollbackFor = Exception.class)public void retryFailedMessages() {// 查詢狀態為PENDING且創建時間超過5分鐘的消息LocalDateTime fiveMinutesAgo = LocalDateTime.now().minusMinutes(5);List<OrderMessage> messages = orderMessageMapper.selectByStatusAndCreateTimeBefore(MessageStatus.PENDING, fiveMinutesAgo);if (CollectionUtils.isEmpty(messages)) {return;}for (OrderMessage message : messages) {// 限制重試次數,避免無限重試if (message.getRetryCount() >= 3) {message.setStatus(MessageStatus.FAILED);message.setUpdateTime(LocalDateTime.now());orderMessageMapper.updateById(message);continue;}try {rabbitTemplate.convertAndSend("order.exchange", "order.created", message);// 發送成功,更新消息狀態message.setStatus(MessageStatus.SENT);message.setRetryCount(message.getRetryCount() + 1);message.setUpdateTime(LocalDateTime.now());orderMessageMapper.updateById(message);} catch (Exception e) {log.error("重試發送消息失敗,消息ID:{}", message.getId(), e);// 更新重試次數message.setRetryCount(message.getRetryCount() + 1);message.setUpdateTime(LocalDateTime.now());orderMessageMapper.updateById(message);}}}
}
5.2 跨庫查詢解決方案
分表分庫后,跨庫查詢變得復雜,常見的解決方案有:
- 應用層聚合:在應用層查詢多個分片,然后聚合結果
- 視圖聚合:在數據庫層創建視圖,聚合多個分片的數據
- 中間件支持:使用 ShardingSphere 等中間件自動處理跨庫查詢
- 讀寫分離 + 只讀庫:將數據同步到只讀庫,在只讀庫上進行跨庫查詢
對于訂單查詢,推薦使用中間件支持+讀寫分離的方案:
package com.jam.order.service;import com.jam.order.entity.Order;
import com.jam.order.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;/*** 跨庫查詢示例** @author 果醬*/
@Slf4j
@Service
public class CrossDbQueryService {private final OrderMapper orderMapper;private final ReadOnlyOrderMapper readOnlyOrderMapper;public CrossDbQueryService(OrderMapper orderMapper, ReadOnlyOrderMapper readOnlyOrderMapper) {this.orderMapper = orderMapper;this.readOnlyOrderMapper = readOnlyOrderMapper;}/*** 查詢指定時間段內的所有訂單(跨庫查詢)* 使用只讀庫進行查詢,避免影響主庫性能*/public List<Order> queryOrdersByTimeRange(LocalDateTime startTime, LocalDateTime endTime) {Objects.requireNonNull(startTime, "開始時間不能為空");Objects.requireNonNull(endTime, "結束時間不能為空");log.info("跨庫查詢訂單,時間范圍:{}至{}", startTime, endTime);// 使用只讀庫進行跨庫查詢return readOnlyOrderMapper.selectByTimeRange(startTime, endTime);}/*** 應用層聚合示例(如果中間件不支持跨庫查詢)*/public List<Order> queryOrdersByTimeRangeWithAppAggregation(LocalDateTime startTime, LocalDateTime endTime) {Objects.requireNonNull(startTime, "開始時間不能為空");Objects.requireNonNull(endTime, "結束時間不能為空");log.info("應用層聚合查詢訂單,時間范圍:{}至{}", startTime, endTime);List<Order> result = new ArrayList<>();// 遍歷所有可能的分片,查詢并聚合結果// 實際應用中需要根據分片規則計算需要查詢的分片for (int dbIndex = 0; dbIndex < 4; dbIndex++) {for (int tableIndex = 0; tableIndex < 4; tableIndex++) {List<Order> orders = orderMapper.selectByTimeRangeAndShard(startTime, endTime, dbIndex, tableIndex);result.addAll(orders);}}return result;}
}
5.3 數據遷移與擴容
隨著業務增長,原有的分表分庫策略可能需要調整,這時候就需要進行數據遷移和擴容。
數據遷移的步驟:
- 準備新的分片:創建新的數據庫和表
- 雙寫數據:同時向舊分片和新分片寫入數據
- 遷移歷史數據:將舊分片的歷史數據遷移到新分片
- 切換路由:將查詢路由到新分片
- 驗證數據:確認新分片數據正確
- 下線舊分片:移除舊的數據庫和表
下面是一個數據遷移工具類的示例:
package com.jam.order.util;import com.jam.order.entity.Order;
import com.jam.order.mapper.OrderMapper;
import com.jam.order.mapper.OrderNewMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;/*** 訂單數據遷移工具** @author 果醬*/
@Slf4j
@Component
public class OrderDataMigrationTool {private final OrderMapper orderMapper;private final OrderNewMapper orderNewMapper;// 線程池,用于并行遷移數據private final ExecutorService executorService = Executors.newFixedThreadPool(4);public OrderDataMigrationTool(OrderMapper orderMapper, OrderNewMapper orderNewMapper) {this.orderMapper = orderMapper;this.orderNewMapper = orderNewMapper;}/*** 遷移指定時間范圍內的訂單數據** @param startTime 開始時間* @param endTime 結束時間* @param batchSize 批次大小*/public void migrateOrders(LocalDateTime startTime, LocalDateTime endTime, int batchSize) {log.info("開始遷移訂單數據,時間范圍:{}至{},批次大小:{}", startTime, endTime, batchSize);long totalCount = orderMapper.countByCreateTimeRange(startTime, endTime);log.info("需要遷移的訂單總數:{}", totalCount);long totalPages = (totalCount + batchSize - 1) / batchSize;log.info("總批次數:{}", totalPages);for (long page = 0; page < totalPages; page++) {long currentPage = page;executorService.submit(() -> {migrateOrderBatch(startTime, endTime, currentPage, batchSize);});}// 等待所有任務完成executorService.shutdown();try {boolean finished = executorService.awaitTermination(24, TimeUnit.HOURS);if (finished) {log.info("所有訂單數據遷移完成");} else {log.error("訂單數據遷移超時未完成");}} catch (InterruptedException e) {log.error("訂單數據遷移被中斷", e);Thread.currentThread().interrupt();}}/*** 遷移單批次訂單數據*/@Transactional(rollbackFor = Exception.class)public void migrateOrderBatch(LocalDateTime startTime, LocalDateTime endTime, long page, int batchSize) {try {log.info("開始遷移批次:{},時間范圍:{}至{}", page, startTime, endTime);// 查詢舊表數據List<Order> orders = orderMapper.selectByCreateTimeRangeWithPage(startTime, endTime, page * batchSize, batchSize);if (CollectionUtils.isEmpty(orders)) {log.info("批次:{} 沒有數據需要遷移", page);return;}// 遷移到新表int insertCount = orderNewMapper.batchInsert(orders);log.info("批次:{} 遷移完成,遷移數量:{}", page, insertCount);} catch (Exception e) {log.error("批次:{} 遷移失敗", page, e);// 可以記錄失敗的批次,以便重試}}/*** 驗證遷移后的數據是否正確*/public void verifyMigration(LocalDateTime startTime, LocalDateTime endTime) {log.info("開始驗證遷移結果,時間范圍:{}至{}", startTime, endTime);// 統計舊表數據量long oldCount = orderMapper.countByCreateTimeRange(startTime, endTime);// 統計新表數據量long newCount = orderNewMapper.countByCreateTimeRange(startTime, endTime);if (oldCount != newCount) {log.error("數據量不一致,舊表:{},新表:{}", oldCount, newCount);return;}log.info("數據量驗證通過,舊表和新表數據量均為:{}", oldCount);// 隨機抽查部分數據int sampleSize = 100;List<Order> oldSamples = orderMapper.selectRandomSamples(startTime, endTime, sampleSize);for (Order oldOrder : oldSamples) {Order newOrder = orderNewMapper.selectById(oldOrder.getId());if (newOrder == null) {log.error("數據缺失,訂單ID:{}", oldOrder.getId());continue;}// 比較訂單關鍵字段if (!oldOrder.getOrderNo().equals(newOrder.getOrderNo()) ||!oldOrder.getUserId().equals(newOrder.getUserId()) ||!oldOrder.getTotalAmount().equals(newOrder.getTotalAmount())) {log.error("數據不一致,訂單ID:{},舊數據:{},新數據:{}", oldOrder.getId(), oldOrder, newOrder);}}log.info("數據驗證完成");}
}
六、分表分庫監控與運維
6.1 監控指標設計
為了確保分表分庫系統的穩定運行,需要監控以下關鍵指標:
-
數據庫指標:
- 各分片的 CPU、內存、磁盤使用率
- 連接數、慢查詢數量
- 讀寫吞吐量、響應時間
-
應用指標:
- 分表分庫相關操作的成功率、響應時間
- 跨庫查詢的比例和性能
- 分布式事務的成功率
-
數據均衡性指標:
- 各分片的數據量差異
- 各分片的 QPS 差異
下面是一個簡單的監控數據收集工具類:
package com.jam.order.monitor;import com.jam.order.entity.ShardingMetrics;
import com.jam.order.mapper.ShardingMetricsMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;/*** 分表分庫監控數據收集器** @author 果醬*/
@Slf4j
@Component
public class ShardingMonitor {private final ShardingMetricsMapper metricsMapper;private final DatabaseMonitorClient databaseMonitorClient;public ShardingMonitor(ShardingMetricsMapper metricsMapper, DatabaseMonitorClient databaseMonitorClient) {this.metricsMapper = metricsMapper;this.databaseMonitorClient = databaseMonitorClient;}/*** 每5分鐘收集一次監控數據*/@Scheduled(fixedRate = 300000)public void collectMetrics() {log.info("開始收集分表分庫監控數據");try {// 獲取所有分片信息List<String> shardNames = databaseMonitorClient.getAllShardNames();for (String shardName : shardNames) {// 收集數據庫指標Map<String, Object> dbMetrics = databaseMonitorClient.getDatabaseMetrics(shardName);// 收集表指標List<String> tableNames = databaseMonitorClient.getTablesInShard(shardName);for (String tableName : tableNames) {Map<String, Object> tableMetrics = databaseMonitorClient.getTableMetrics(shardName, tableName);// 保存監控數據ShardingMetrics metrics = new ShardingMetrics();metrics.setShardName(shardName);metrics.setTableName(tableName);metrics.setCollectTime(LocalDateTime.now());metrics.setRowCount(((Number) tableMetrics.get("rowCount")).longValue());metrics.setReadQps(((Number) tableMetrics.get("readQps")).doubleValue());metrics.setWriteQps(((Number) tableMetrics.get("writeQps")).doubleValue());metrics.setAvgQueryTime(((Number) tableMetrics.get("avgQueryTime")).doubleValue());metrics.setCpuUsage(((Number) dbMetrics.get("cpuUsage")).doubleValue());metrics.setMemoryUsage(((Number) dbMetrics.get("memoryUsage")).doubleValue());metrics.setConnectionCount(((Number) dbMetrics.get("connectionCount")).intValue());metricsMapper.insert(metrics);}}log.info("分表分庫監控數據收集完成");} catch (Exception e) {log.error("收集分表分庫監控數據失敗", e);}}/*** 檢測數據均衡性*/@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1點執行public void checkDataBalance() {log.info("開始檢測數據均衡性");try {// 獲取各分片的數據量List<Map<String, Object>> shardDataCount = metricsMapper.selectShardDataCount();if (CollectionUtils.isEmpty(shardDataCount)) {log.info("沒有分片數據,無需檢測均衡性");return;}// 計算平均值和標準差long total = 0;for (Map<String, Object> data : shardDataCount) {total += ((Number) data.get("totalCount")).longValue();}double avg = total / (double) shardDataCount.size();double variance = 0;for (Map<String, Object> data : shardDataCount) {long count = ((Number) data.get("totalCount")).longValue();variance += Math.pow(count - avg, 2);}variance /= shardDataCount.size();double stdDev = Math.sqrt(variance);// 計算變異系數(標準差/平均值)double cv = stdDev / avg;log.info("數據均衡性檢測結果:平均值={}, 標準差={}, 變異系數={}", avg, stdDev, cv);// 如果變異系數大于0.2,說明數據分布不均勻if (cv > 0.2) {log.warn("數據分布不均勻,變異系數:{},建議進行數據重平衡", cv);// 可以發送告警通知} else {log.info("數據分布均勻,變異系數:{}", cv);}} catch (Exception e) {log.error("檢測數據均衡性失敗", e);}}
}
6.2 常見問題排查
分表分庫系統可能遇到的常見問題及排查方法:
-
數據不一致:
- 檢查分片鍵是否正確
- 檢查分布式事務實現是否正確
- 對比新舊數據,找出差異點
-
查詢性能差:
- 檢查是否使用了分片鍵查詢
- 檢查是否有大量跨庫查詢
- 檢查索引是否合理
-
數據傾斜:
- 分析分片鍵的分布情況
- 調整分片算法
- 進行數據重平衡
-
擴容困難:
- 檢查數據遷移工具是否正常工作
- 優化遷移策略,減少停機時間
- 考慮使用自動化遷移工具
七、參考
- 單表數據量閾值:參考 MySQL 官方文檔(https://dev.mysql.com/doc/refman/8.0/en/table-size-limit.html)
- 分庫分表命名規范:參考阿里巴巴《Java 開發手冊(嵩山版)》
- 分片鍵選擇原則:參考 Apache ShardingSphere 官方文檔(https://shardingsphere.apache.org/documentation/5.4.0/en/concepts/sharding/)
- 分布式事務解決方案:參考《Designing Data-Intensive Applications》(Martin Kleppmann 著)
- 數據遷移策略:參考 AWS 數據庫遷移最佳實踐(https://aws.amazon.com/cn/dms/best-practices/)
- 監控指標設計:參考 Prometheus 官方文檔(Metric types | Prometheus)