随着业务数据量的爆发式增长,单库单表的MySQL数据库很快会遇到性能瓶颈。当索引优化、SQL改写、缓存升级甚至硬件扩容都无法支撑业务的并发与存储需求时,分库分表就成为了数据库架构升级的核心方案。 分库分表的本质,是通过对数据的垂直与水平拆分,将原本集中在单库单表的压力分散到多个数据库实例与数据表中,从而突破单机的性能上限,提升系统的吞吐量、稳定性与可扩展性。
一、分库分表的核心拆分维度
分库分表分为两大拆分方向:垂直拆分与水平拆分,二者解决的痛点完全不同,必须先明确区分,避免混用。
1.1 垂直拆分
垂直拆分以字段/业务模块为拆分维度,分为垂直分库与垂直分表两类。
1.1.1 垂直分库
垂直分库是按照业务领域边界,将原本集中在一个库中的不同业务表,拆分到多个独立的数据库中,每个库对应一个独立的业务模块。 比如电商系统中,将用户相关表拆分到user_db,订单相关表拆分到order_db,商品相关表拆分到product_db,支付相关表拆分到pay_db,每个库部署在独立的服务器上。
- 核心解决的问题:单库的连接数上限、IO带宽、CPU负载瓶颈,分散并发压力,实现业务的物理隔离,避免单个业务的故障影响全系统。
- 底层逻辑:数据库的连接数、磁盘IO、CPU资源都是有限的,多个业务模块共用一个库,会出现资源争抢,垂直分库将资源隔离,每个业务独享数据库资源,提升整体性能。
1.1.2 垂直分表
垂直分表是按照字段的访问频率、数据大小,将一张大表拆分为多张结构不同的表,分为主表与扩展表,主表存储高频访问的小字段,扩展表存储低频访问的大字段。 比如用户表,主表user_main存储user_id、username、phone、status等高频访问字段,扩展表user_ext存储user_id、avatar、signature、address、ext_info等低频访问的大字段,二者通过user_id关联。
- 核心解决的问题:单行数据过大导致的InnoDB B+树查询性能下降问题。
- 底层逻辑:InnoDB的默认页大小为16KB,B+树的每个叶子节点对应一个数据页。单行数据的体积越大,单个数据页能存储的行数就越少,查询相同范围的数据需要的磁盘IO次数就越多,性能越差。垂直分表将高频小字段集中在主表,大幅降低单行数据体积,提升主表的查询性能。
1.2 水平拆分(分片)
水平拆分以行数据为拆分维度,按照指定的规则,将一张表的行数据拆分到多张结构完全相同的表中,这些表被称为分片表,拆分规则被称为分片策略。 如果拆分后的分片表分布在多个数据库中,就是分库+分表;如果都在同一个数据库中,就是单库分表。
- 核心解决的问题:单表数据量过大导致的查询、写入性能衰减问题。
- 权威阈值:InnoDB存储引擎中,当单表数据量超过1000万行时,B+树的层级会从3层增长到4层,每次查询需要多一次磁盘IO操作,性能会出现明显的线性衰减。当单表数据量超过5000万行时,绝大多数场景下的SQL优化都无法带来明显的性能提升,必须进行水平拆分。
二、核心分片策略
分片策略是水平拆分的核心,决定了数据如何分布到各个分片表中,直接影响分库分表后的查询性能、数据均匀性与扩容难度。 在选择分片策略之前,必须先确定分片键,分片键是用于拆分数据的字段,是分片策略的核心,分片键的选择直接决定了分库分表的成败。
2.1 分片键的选择黄金原则
- 高频查询覆盖原则:分片键必须覆盖业务中90%以上的查询场景,避免出现不带分片键的查询,导致全分片扫描,性能急剧下降。
- 数据均匀分布原则:分片键的取值必须尽可能均匀,避免出现数据倾斜,比如不要用订单状态、性别等枚举值作为分片键,否则会出现某个分片数据量远超其他分片的热点问题。
- 不可变更原则:分片键的值一旦写入,绝对不允许修改,否则会导致数据需要在不同分片之间迁移,引发分布式事务、数据不一致等一系列问题。
最常用的分片键:订单系统的order_id、user_id,用户系统的user_id,商品系统的product_id等全局唯一的主键字段。
2.2 主流分片策略详解
2.2.1 哈希取模分片
哈希取模分片是生产环境中最常用的分片策略,核心逻辑是对分片键的值进行哈希计算,再对分片总数取模,最终得到数据所在的分片序号。
- 核心公式:
分片序号 = hash(分片键) % 分片总数 - 适用场景:用户、订单等核心交易表,查询场景固定,对数据均匀性要求高的业务。
- 核心优势:实现简单,数据分布均匀,带分片键的查询性能稳定,只需一次路由即可定位到目标分片。
- 核心劣势:扩容难度大,分片总数变更后,所有数据的分片序号都需要重新计算,全量数据迁移成本极高。
SQL实例:4分片订单表创建
CREATE TABLE IF NOT EXISTS `t_order_0` (
`order_id` bigint NOT NULL COMMENT '订单ID',
`user_id` bigint NOT NULL COMMENT '用户ID(分片键)',
`order_amount` decimal(12,2) NOT NULL COMMENT '订单金额',
`order_status` tinyint NOT NULL COMMENT '订单状态',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单分片表0';
CREATE TABLE IF NOT EXISTS `t_order_1` LIKE `t_order_0`;
CREATE TABLE IF NOT EXISTS `t_order_2` LIKE `t_order_0`;
CREATE TABLE IF NOT EXISTS `t_order_3` LIKE `t_order_0`;
Java实例:哈希取模分片算法实现
package com.jam.demo.sharding.algorithm;
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 org.springframework.util.ObjectUtils;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Properties;
/**
* 哈希取模分片算法
* @author ken
*/
public final class HashModShardingAlgorithm implements StandardShardingAlgorithm<Long> {
private static final String SHARDING_COUNT_KEY = "sharding-count";
private int shardingCount;
@Override
public void init(Properties props) {
if (!props.containsKey(SHARDING_COUNT_KEY)) {
throw new IllegalArgumentException("分片数量配置不能为空");
}
this.shardingCount = Integer.parseInt(props.getProperty(SHARDING_COUNT_KEY));
}
/**
* 精准分片路由(=、IN查询)
* @param availableTargetNames 可用的分片表名集合
* @param shardingValue 分片键值
* @return 目标分片表名
*/
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
Long shardingKey = shardingValue.getValue();
if (ObjectUtils.isEmpty(shardingKey)) {
throw new IllegalArgumentException("分片键值不能为空");
}
long mod = Math.abs(shardingKey.hashCode()) % shardingCount;
String targetTable = shardingValue.getLogicTableName() + "_" + mod;
if (availableTargetNames.contains(targetTable)) {
return targetTable;
}
throw new IllegalStateException("未找到匹配的分片表:" + targetTable);
}
/**
* 范围分片路由(BETWEEN、>、<查询)
* @param availableTargetNames 可用的分片表名集合
* @param shardingValue 分片键范围值
* @return 目标分片表名集合
*/
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) {
return new LinkedHashSet<>(availableTargetNames);
}
@Override
public String getType() {
return "HASH_MOD";
}
}
2.2.2 一致性哈希分片
一致性哈希分片是为了解决哈希取模分片扩容痛点设计的,核心逻辑是将哈希空间组织成一个0~2^32-1的环形结构,每个分片节点对应环上的一个固定位置,数据的哈希值落在环上的位置后,顺时针找到的第一个节点,就是该数据的存储分片。 为了解决数据倾斜问题,一致性哈希引入了虚拟节点机制,每个物理分片节点对应多个虚拟节点,虚拟节点均匀分布在哈希环上,大幅提升数据分布的均匀性。
- 适用场景:需要频繁扩容、节点动态变化的分布式系统,对扩容灵活性要求高的业务。
- 核心优势:扩容时仅需要迁移环上相邻节点的部分数据,数据迁移量极小,无需全量数据重算。
- 核心劣势:实现复杂,范围查询需要全分片扫描,数据均匀性高度依赖虚拟节点的数量。
一致性哈希路由流程图
Java实例:带虚拟节点的一致性哈希实现
package com.jam.demo.sharding.algorithm;
import com.google.common.collect.Lists;
import org.springframework.util.ObjectUtils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* 带虚拟节点的一致性哈希算法
* @author ken
*/
public class ConsistentHashAlgorithm<T> {
private static final int DEFAULT_VIRTUAL_NODE_COUNT = 16;
private final int virtualNodeCount;
private final SortedMap<Long, T> hashRing = new TreeMap<>();
private final MessageDigest md5Digest;
public ConsistentHashAlgorithm(List<T> realNodes) throws NoSuchAlgorithmException {
this(realNodes, DEFAULT_VIRTUAL_NODE_COUNT);
}
public ConsistentHashAlgorithm(List<T> realNodes, int virtualNodeCount) throws NoSuchAlgorithmException {
this.virtualNodeCount = virtualNodeCount;
this.md5Digest = MessageDigest.getInstance("MD5");
for (T node : realNodes) {
addNode(node);
}
}
/**
* 添加节点到哈希环
* @param node 真实节点
*/
public void addNode(T node) {
for (int i = 0; i < virtualNodeCount; i++) {
String virtualNodeKey = node.toString() + "#VN" + i;
long hash = hash(virtualNodeKey);
hashRing.put(hash, node);
}
}
/**
* 从哈希环移除节点
* @param node 真实节点
*/
public void removeNode(T node) {
for (int i = 0; i < virtualNodeCount; i++) {
String virtualNodeKey = node.toString() + "#VN" + i;
long hash = hash(virtualNodeKey);
hashRing.remove(hash);
}
}
/**
* 获取数据对应的目标节点
* @param key 分片键值
* @return 目标节点
*/
public T getTargetNode(String key) {
if (ObjectUtils.isEmpty(key)) {
throw new IllegalArgumentException("分片键值不能为空");
}
long hash = hash(key);
SortedMap<Long, T> tailMap = hashRing.tailMap(hash);
Long targetHash = tailMap.isEmpty() ? hashRing.firstKey() : tailMap.firstKey();
return hashRing.get(targetHash);
}
/**
* MD5哈希计算,生成32位哈希值
* @param key 待哈希的字符串
* @return 哈希值
*/
private long hash(String key) {
byte[] digest = md5Digest.digest(key.getBytes());
return ((long) (digest[3] & 0xFF) << 24)
| ((long) (digest[2] & 0xFF) << 16)
| ((long) (digest[1] & 0xFF) << 8)
| (digest[0] & 0xFF);
}
}
2.2.3 范围分片
范围分片是按照分片键的数值范围、时间范围进行拆分,每个分片对应一个连续的范围区间。 常见的场景:按订单创建时间,每个月对应一个分片表;按用户ID,11000万对应0号分片,10012000万对应1号分片,以此类推。
- 适用场景:日志、监控、统计等时序数据,或者需要按范围批量查询的业务。
- 核心优势:范围查询效率极高,只需路由到对应范围的分片,无需全分片扫描;扩容简单,只需新增分片即可,无需迁移历史数据。
- 核心劣势:极易出现数据热点,比如最新的时间分片的访问量远高于历史分片,导致数据库压力分布不均,数据倾斜严重。
SQL实例:时间范围分片表创建
CREATE TABLE IF NOT EXISTS `t_order_202601` (
`order_id` bigint NOT NULL COMMENT '订单ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`order_amount` decimal(12,2) NOT NULL COMMENT '订单金额',
`order_status` tinyint NOT NULL COMMENT '订单状态',
`create_time` datetime NOT NULL COMMENT '创建时间(分片键)',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`order_id`, `create_time`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='2026年01月订单分片表';
CREATE TABLE IF NOT EXISTS `t_order_202602` LIKE `t_order_202601`;
CREATE TABLE IF NOT EXISTS `t_order_202603` LIKE `t_order_202601`;
2.2.4 枚举分片
枚举分片是按照分片键的固定枚举值进行拆分,每个枚举值对应一个固定的分片。 常见场景:按地区拆分,华东地区对应0号库,华北地区对应1号库;按订单类型拆分,普通订单对应0号表,虚拟订单对应1号表。
- 适用场景:分片键的取值固定、数量有限的业务场景。
- 核心优势:实现简单,查询路由精准,扩容灵活,可单独为某个枚举值新增分片。
- 核心劣势:分片数量有限,极易出现数据倾斜,不适合分片键取值动态变化的场景。
2.2.5 复合分片
复合分片是结合多种分片策略的组合方案,最常用的是先分库、后分表的模式,比如先按user_id哈希取模分库,再按订单创建时间范围分表。 复合分片解决了单维度分片的痛点,既能保证数据均匀分布,又能支持范围查询,适合超大规模数据的业务场景。
三、跨库事务解决方案
分库分表后,一个业务操作往往需要操作多个数据库实例,单库的本地事务无法保证多个库操作的原子性,这就是分布式事务问题。 分布式事务的核心矛盾,是一致性、可用性、性能三者的平衡,根据业务对一致性的要求,分为强一致性方案与最终一致性方案两大类。
3.1 强一致性方案:XA两阶段提交(2PC)
XA是X/Open组织定义的分布式事务规范,基于两阶段提交(2PC)实现,是数据库层面的强一致性分布式事务方案。
3.1.1 核心原理
XA事务分为两个阶段:
- 准备阶段(Prepare) :事务管理器(TM)向所有参与事务的资源管理器(RM,即数据库)发送Prepare请求,每个RM执行本地事务操作,但不提交,同时锁定资源,向TM返回执行结果。
- 提交阶段(Commit) :如果所有RM都返回Prepare成功,TM向所有RM发送Commit请求,所有RM提交本地事务,释放资源,事务完成;如果有任意一个RM返回Prepare失败,TM向所有RM发送Rollback请求,所有RM回滚本地事务,释放资源。
MySQL 8.0的InnoDB存储引擎完整支持XA事务规范,保证了本地事务与XA事务的ACID特性。
- 适用场景:核心交易场景,对数据一致性要求极高,并发量相对可控的场景,比如支付、转账。
- 核心优势:强一致性,完全符合ACID特性,业务代码无侵入,实现简单。
- 核心劣势:性能差,整个事务过程中资源被锁定,锁等待时间长,并发能力低;可用性差,事务管理器发生故障时,会导致资源长期锁定,出现阻塞。
Maven依赖配置
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jta-atomikos</artifactId>
<version>3.2.4</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
</dependencies>
Java实例:XA编程式事务实现
package com.jam.demo.transaction;
import com.atomikos.icatch.jta.UserTransactionImp;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.jta.JtaTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.transaction.UserTransaction;
import java.math.BigDecimal;
/**
* XA分布式事务服务
* @author ken
*/
@Slf4j
@Service
public class XaTransactionService {
@Resource(name = "orderJdbcTemplate")
private JdbcTemplate orderJdbcTemplate;
@Resource(name = "payJdbcTemplate")
private JdbcTemplate payJdbcTemplate;
@Resource
private JtaTransactionManager jtaTransactionManager;
/**
* 订单创建与支付记录插入的XA事务
* @param orderId 订单ID
* @param userId 用户ID
* @param amount 订单金额
*/
public void createOrderWithPay(Long orderId, Long userId, BigDecimal amount) {
TransactionTemplate transactionTemplate = new TransactionTemplate(jtaTransactionManager);
transactionTemplate.execute(status -> {
try {
String orderSql = "INSERT INTO t_order (order_id, user_id, order_amount, order_status) VALUES (?, ?, ?, 1)";
orderJdbcTemplate.update(orderSql, orderId, userId, amount);
String paySql = "INSERT INTO t_pay_record (pay_id, order_id, user_id, pay_amount, pay_status) VALUES (?, ?, ?, ?, 1)";
payJdbcTemplate.update(paySql, generatePayId(), orderId, userId, amount);
return Boolean.TRUE;
} catch (Exception e) {
status.setRollbackOnly();
log.error("XA事务执行失败,已回滚", e);
throw new RuntimeException("事务执行失败", e);
}
});
}
private Long generatePayId() {
return System.currentTimeMillis() * 1000 + (long) (Math.random() * 1000);
}
}
3.2 最终一致性方案:柔性事务
柔性事务放弃了强一致性,只保证数据的最终一致性,大幅提升了系统的并发能力与可用性,是高并发场景下的首选方案。
3.2.1 TCC事务
TCC(Try-Confirm-Cancel)是业务层的分布式事务方案,完全由业务代码实现,分为三个阶段:
- Try阶段:资源检查与预留,比如冻结用户账户的可用余额,锁定库存,完成所有业务前置检查。
- Confirm阶段:确认执行业务操作,使用Try阶段预留的资源,完成业务提交,该阶段必须保证幂等。
- Cancel阶段:取消业务操作,释放Try阶段预留的资源,回滚业务,该阶段必须保证幂等,同时处理空回滚、悬挂问题。
- 适用场景:高并发核心交易场景,对性能与一致性都有较高要求的业务。
- 核心优势:性能高,无长期资源锁定,并发能力强;可用性高,单个分支失败可通过补偿回滚,不会阻塞整个事务。
- 核心劣势:对业务代码侵入性极强,开发成本高,需要处理幂等、空回滚、悬挂三大核心问题。
TCC三大核心问题解决方案
- 幂等性:通过事务ID+分支ID的唯一索引,保证每个操作只执行一次,避免重复提交。
- 空回滚:Cancel阶段先检查是否执行过Try阶段,如果没有执行过Try,直接返回成功,不执行回滚逻辑。
- 悬挂问题:Try阶段先检查是否已经执行过Cancel阶段,如果已经执行过Cancel,直接拒绝执行Try,避免后续Cancel无法回滚新预留的资源。
Java实例:TCC库存扣减实现
package com.jam.demo.tcc;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* TCC库存服务实现
* @author ken
*/
@Slf4j
@Service
public class TccStockService {
private final StockMapper stockMapper;
private final StockTccLogMapper tccLogMapper;
public TccStockService(StockMapper stockMapper, StockTccLogMapper tccLogMapper) {
this.stockMapper = stockMapper;
this.tccLogMapper = tccLogMapper;
}
/**
* Try阶段:冻结库存
* @param txId 全局事务ID
* @param productId 商品ID
* @param num 扣减数量
* @return 执行结果
*/
@Transactional(rollbackFor = Exception.class)
public boolean tryFreezeStock(String txId, Long productId, Integer num) {
StockTccLog existLog = tccLogMapper.selectById(txId);
if (!ObjectUtils.isEmpty(existLog)) {
return existLog.getStatus() != 2;
}
StockTccLog cancelLog = tccLogMapper.selectById(txId);
if (!ObjectUtils.isEmpty(cancelLog) && cancelLog.getStatus() == 2) {
throw new IllegalStateException("事务已回滚,拒绝执行Try操作");
}
int update = stockMapper.freezeStock(productId, num);
if (update <= 0) {
throw new RuntimeException("库存冻结失败,库存不足");
}
StockTccLog tccLog = new StockTccLog();
tccLog.setTxId(txId);
tccLog.setProductId(productId);
tccLog.setNum(num);
tccLog.setStatus(1);
tccLog.setCreateTime(LocalDateTime.now());
tccLogMapper.insert(tccLog);
return true;
}
/**
* Confirm阶段:确认扣减库存
* @param txId 全局事务ID
* @return 执行结果
*/
@Transactional(rollbackFor = Exception.class)
public boolean confirmDeductStock(String txId) {
StockTccLog tccLog = tccLogMapper.selectById(txId);
if (ObjectUtils.isEmpty(tccLog)) {
return true;
}
if (tccLog.getStatus() == 9) {
return true;
}
stockMapper.confirmDeduct(tccLog.getProductId(), tccLog.getNum());
tccLog.setStatus(9);
tccLogMapper.updateById(tccLog);
return true;
}
/**
* Cancel阶段:释放冻结库存
* @param txId 全局事务ID
* @return 执行结果
*/
@Transactional(rollbackFor = Exception.class)
public boolean cancelReleaseStock(String txId) {
StockTccLog tccLog = tccLogMapper.selectById(txId);
if (ObjectUtils.isEmpty(tccLog)) {
StockTccLog cancelLog = new StockTccLog();
cancelLog.setTxId(txId);
cancelLog.setStatus(2);
cancelLog.setCreateTime(LocalDateTime.now());
tccLogMapper.insert(cancelLog);
return true;
}
if (tccLog.getStatus() == 2 || tccLog.getStatus() == 9) {
return true;
}
stockMapper.releaseFreeze(tccLog.getProductId(), tccLog.getNum());
tccLog.setStatus(2);
tccLogMapper.updateById(tccLog);
return true;
}
@Data
@TableName("t_stock")
@Schema(description = "商品库存表")
public static class Stock implements Serializable {
@TableId(type = IdType.AUTO)
@Schema(description = "主键ID")
private Long id;
@Schema(description = "商品ID")
private Long productId;
@Schema(description = "可用库存")
private Integer availableStock;
@Schema(description = "冻结库存")
private Integer frozenStock;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}
@Data
@TableName("t_stock_tcc_log")
@Schema(description = "库存TCC事务日志表")
public static class StockTccLog implements Serializable {
@TableId
@Schema(description = "全局事务ID")
private String txId;
@Schema(description = "商品ID")
private Long productId;
@Schema(description = "操作数量")
private Integer num;
@Schema(description = "状态:1-try中,2-已回滚,9-已提交")
private Integer status;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
int freezeStock(Long productId, Integer num);
int confirmDeduct(Long productId, Integer num);
int releaseFreeze(Long productId, Integer num);
}
@Mapper
public interface StockTccLogMapper extends BaseMapper<StockTccLog> {
}
}
3.2.2 事务消息方案
事务消息是基于消息中间件实现的最终一致性方案,RocketMQ等主流消息队列都提供了事务消息功能,本质是将本地消息表封装到了MQ内部,对业务代码无侵入。 核心流程:
- 生产者发送半消息到MQ,此时消息对消费者不可见。
- 生产者执行本地事务。
- 本地事务执行成功,向MQ发送Commit请求,消息对消费者可见;执行失败,发送Rollback请求,MQ删除消息。
- 超时未收到确认请求,MQ触发事务回查,查询生产者本地事务的执行状态,根据回查结果决定提交或回滚消息。
- 消费者消费消息,执行本地事务,完成业务闭环。
- 适用场景:高并发、非核心联动业务场景,比如订单创建后通知积分、物流、统计系统。
- 核心优势:业务代码无侵入,性能高,可靠性强,无锁阻塞。
- 核心劣势:只支持最终一致性,依赖消息中间件的可用性。
四、平滑扩容方案
随着业务数据量的增长,原有的分片数量很快会无法支撑业务需求,必须进行扩容。扩容的核心目标是:数据零丢失、业务无感知、服务无停机。
4.1 双倍停机扩容法
双倍停机扩容法是最简单、最稳妥的扩容方案,核心逻辑是分片数量始终按2的倍数扩容,比如从4分片扩到8分片,8分片扩到16分片。
4.1.1 核心原理
原分片规则:分片序号 = hash(key) % N 新分片规则:分片序号 = hash(key) % 2N 对于原分片i中的数据,新的分片序号只会是i或者i+N,因此每个原分片只需要迁移i+N的数据到新分片,剩余数据保留在原分片,无需全量数据重算,数据迁移量减少50%。
4.1.2 扩容流程
- 适用场景:中小规模系统,业务低峰期可接受短时间停机的场景。
- 核心优势:实现简单,数据迁移量小,校验逻辑简单,风险可控。
- 核心劣势:需要停机,业务有中断,不适合高可用要求极高的系统。
4.2 预分片+水平扩库(无停机最佳实践)
预分片+水平扩库是生产环境中首选的无停机扩容方案,核心逻辑是提前规划好足够的分片数量,将大量分片表预先分布在少量的数据库实例中,扩容时只需将部分分片表整表迁移到新的数据库实例,无需修改分片规则,无需迁移单条数据,对业务完全无感知。
4.2.1 核心原理
分片规则分为两层:
- 分片键到分表的映射:固定不变,比如提前分1024个分表,映射规则为
表序号 = hash(user_id) % 1024,永远不变。 - 分表到数据库的映射:动态可调整,初始时1024个分表分布在4个库中,每个库256个表;扩容时,将部分分表从原库迁移到新库,只需修改分表到库的映射关系,业务代码完全无需修改。
4.2.2 扩容架构
4.2.3 扩容流程
- 提前预分片:业务上线前,根据未来3年的容量规划,创建足够的分表(推荐1024/2048个),分布在初始的数据库实例中。
- 扩容准备:当数据库实例的CPU、IO、连接数达到阈值时,准备新的数据库实例,创建与原库完全一致的分表结构。
- 数据同步:通过MySQL主从同步,将需要迁移的分表从原库同步到新库,全量同步完成后,追平增量binlog,保证主从数据完全一致。
- 配置切换:业务低峰期,修改Sharding-JDBC的分片配置,将迁移的分表的数据源切换到新库,灰度发布到部分服务节点。
- 业务验证:验证灰度节点的读写业务正常,无报错,数据写入正确,逐步全量发布配置。
- 收尾清理:确认全量业务正常后,断开主从同步,删除原库中已迁移的分表,完成扩容。
- 适用场景:中大规模高并发系统,对可用性要求极高,不允许停机的生产环境。
- 核心优势:完全无停机,业务无感知,数据迁移风险极低,扩容灵活,无需修改分片规则与业务代码。
- 核心劣势:需要提前做好容量规划,对架构设计有一定要求。
五、分库分表避坑指南
5.1 分布式ID生成
分库分表后,单表的自增主键无法保证全局唯一,必须使用分布式ID生成方案,推荐使用雪花算法(Snowflake),64位结构如下:
- 1位:符号位,固定为0
- 41位:时间戳,精确到毫秒,可使用69年
- 10位:机器ID,支持1024个节点
- 12位:序列号,每毫秒支持4096个ID
Java实例:雪花算法实现
package com.jam.demo.common;
import org.springframework.util.ObjectUtils;
/**
* 雪花算法分布式ID生成器
* @author ken
*/
public class SnowflakeIdGenerator {
private static final long EPOCH = 1704067200000L;
private static final long WORKER_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = (1L << WORKER_ID_BITS) - 1;
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
private static final long SEQUENCE_MASK = (1L << SEQUENCE_BITS) - 1;
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("机器ID超出范围");
}
this.workerId = workerId;
}
/**
* 生成下一个分布式ID
* @return 全局唯一ID
*/
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,无法生成ID");
}
if (currentTimestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
currentTimestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = currentTimestamp;
return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
5.2 常见坑点与解决方案
- 跨分片JOIN:避免使用跨分片JOIN,解决方案:字段冗余、数据同步到宽表、使用Elasticsearch等搜索引擎做关联查询。
- 全分片扫描:所有查询必须带上分片键,避免不带分片键的查询导致全分片扫描,性能急剧下降。
- 数据倾斜:定期监控各分片的数据量与访问量,及时调整分片策略,避免热点分片。
- 深分页问题:避免使用
limit offset, size的深分页查询,解决方案:游标分页,带上上一页的最大主键,where id > lastId limit size。 - 过度拆分:分库分表会大幅提升系统复杂度,优先优化SQL、索引、缓存,只有当这些优化都无法解决问题时,才考虑分库分表。
分库分表是数据库架构升级的重要手段,它能有效突破单库单表的性能瓶颈,但同时也带来了分布式系统的复杂度。 在实际生产中,我们需要根据业务的实际场景,合理选择分片策略、事务方案与扩容方案,平衡一致性、可用性与性能三者的关系,同时做好提前规划与风险控制,才能让分库分表真正发挥价值,支撑业务的长期增长。