分布式通用方案:雪花算法、限流、定时任务与分库分表
分布式通用方案:雪花算法、限流、定时任务与分库分表
一、雪花算法(Snowflake)
1.1 为什么需要分布式 ID
| 方案 | 问题 |
|------|------|
| 数据库自增 ID | 分库分表后 ID 重复 |
| UUID | 无序,B+ 树索引频繁分裂,性能差 |
| Redis 递增 | 依赖 Redis,有单点风险 |
雪花算法的优势:
- 全局唯一
- 趋势递增(利于 B+ 树索引)
- 高性能(本地生成,无网络开销)
- 不依赖第三方服务
1.2 位结构(64 bit)
0 | 41 bit 时间戳 | 10 bit 机器 ID | 12 bit 序列号
各部分说明:
0(1 bit):符号位,固定 0(正数)
时间戳(41 bit):毫秒级,可用 69 年(2^41 / 1000/60/60/24/365 ≈ 69)
机器 ID(10 bit):最多 1024 台机器(可拆分为 5 bit 机房 + 5 bit 机器)
序列号(12 bit):同一毫秒内最多 4096 个 ID
public class SnowflakeIdGenerator {
private final long workerId; // 机器 ID
private final long datacenterId; // 机房 ID
private long sequence = 0L; // 序列号
private long lastTimestamp = -1L; // 上次时间戳
// 位偏移
private static final long SEQUENCE_BITS = 12L;
private static final long WORKER_BITS = 5L;
private static final long DATACENTER_BITS = 5L;
private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS;
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_BITS;
// 最大值
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); // 4095
private static final long MAX_WORKER_ID = ~(-1L << WORKER_BITS); // 31
private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_BITS); // 31
// 起始时间戳(自定义,ID 从这个时间开始计算)
private static final long START_TIMESTAMP = 1609459200000L; // 2021-01-01
public SnowflakeIdGenerator(long datacenterId, long workerId) {
if (datacenterId < 0 || datacenterId > MAX_DATACENTER_ID) {
throw new IllegalArgumentException("datacenterId 超出范围");
}
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("workerId 超出范围");
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成 ID");
}
// 同一毫秒内,序列号递增
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 同一毫秒序列号用完,等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L; // 新的毫秒,序列号从 0 开始
}
lastTimestamp = timestamp;
// 拼接 ID
return ((timestamp - START_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
1.3 时钟回拨问题
问题:服务器时钟被 NTP 同步回拨,导致生成重复 ID。
解决方案:
| 方案 | 说明 |
|------|------|
| 拒绝生成 | 回拨时抛异常(上面代码的做法) |
| 等待追平 | 回拨时间短时(< 15ms),等待追平 |
| 扩展位 | 用 1 bit 表示是否回拨,用不同算法生成 |
| 百度 UidGenerator | 解决了时钟回拨问题,用 Ring Buffer |
| 美团 Leaf | 提供号段模式和雪花模式 |
1.4 生产实践
# Spring Boot 集成
mybatis-plus:
global-config:
db-config:
id-type: ASSIGN_ID # 内置雪花算法(MyBatis-Plus)
// Hutool 工具
long id = IdUtil.getSnowflake(workerId, datacenterId).nextId();
// 美团 Leaf(需要部署 Leaf Server)
// 号段模式:从数据库获取号段,本地递增分配
// 雪花模式:标准雪花算法 + ZooKeeper 分配 workerId
二、限流算法
2.1 固定窗口计数器
原理:
每 1 秒一个窗口,每个窗口最多 100 个请求
窗口内计数,超过 100 就拒绝
+----[0-1s: 100]----[1-2s: 100]----[2-3s: 100]----+
问题:临界问题
在 0.9s ~ 1.1s 之间,两个窗口各有 100 个请求
实际 0.2s 内处理了 200 个请求(超过限流目的)
2.2 滑动窗口
原理:
把 1 秒分成 10 个小格(每个 100ms)
滑动窗口统计最近 10 个小格的总数
[_______窗口_______]
[__][__][__][__][__][__][__][__][__][__]
每 100ms 滑动一次,去掉最老的,加最新的
优势:解决了固定窗口的临界问题
实现:Sentinel 底层用的就是滑动窗口
2.3 漏桶算法(Leaky Bucket)
原理:
水(请求)流入桶子,桶子以固定速率流出(处理)
桶满了就溢出(拒绝)
特点:
无论流入多快,流出速率固定
适合"平滑流量"场景
缺点:
无法应对突发流量(即使系统有能力处理)
实现:
Guava RateLimiter 的 SmoothRateLimiter 就是漏桶变种
// Guava 漏桶实现
RateLimiter rateLimiter = RateLimiter.create(100); // 每秒处理 100 个
public void handleRequest() {
rateLimiter.acquire(); // 阻塞等待令牌
// 或者
if (rateLimiter.tryAcquire()) {
// 获取到令牌,处理请求
} else {
// 没有令牌,拒绝
}
}
2.4 令牌桶算法(Token Bucket)
原理:
系统以固定速率往桶里放令牌
请求来时需要先获取令牌
没有令牌就等待或拒绝
特点:
允许突发流量(桶里有足够令牌时可以一次性处理多个请求)
比漏桶更灵活
对比:
漏桶:流出速率固定(保护下游,但限制了自己)
令牌桶:流入速率由桶中令牌控制(既能限流又能应对突发)
2.5 四种算法对比
| 算法 | 是否允许突发 | 实现复杂度 | 适用场景 |
|------|-------------|-----------|----------|
| 固定窗口 | 是(临界问题) | 低 | 简单限流 |
| 滑动窗口 | 是 | 中 | Sentinel 流控 |
| 漏桶 | 否(固定速率) | 中 | 平滑流量 |
| 令牌桶 | 是 | 中 | Gateway/API 限流 |
三、XXL-Job 分布式定时任务
3.1 为什么需要 XXL-Job
单机定时任务(@Scheduled)的问题:
1. 服务多实例部署时,每个实例都会执行(重复执行)
2. 没有执行记录和监控
3. 失败没有重试和告警
4. 不能动态修改 cron 表达式(需要重启)
XXL-Job 解决的问题:
1. 调度中心统一管理任务
2. 执行器注册到调度中心,分布式部署
3. 路由策略(轮询、随机、一致性 HASH、故障转移等)
4. 执行日志、失败重试、邮件告警
5. 动态修改 cron,无需重启
3.2 架构
调度中心(Admin) -> 按 cron 触发任务 -> 路由策略 -> 执行器(Executor)
↓
执行结果回调 -> 调度中心记录日志
调度中心:部署一个,负责任务调度和管理
执行器:嵌入到业务服务中,负责接收调度并执行任务
3.3 使用方式
// 1. 引入依赖
// <dependency>
// <groupId>com.xuxueli</groupId>
// <artifactId>xxl-job-core</artifactId>
// </dependency>
// 2. 配置执行器
@Configuration
public class XxlJobConfig {
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses("http://xxl-job-admin:8080/xxl-job-admin");
executor.setAppname("cherry-shop-executor");
executor.setPort(9999);
return executor;
}
}
// 3. 编写任务处理器
@Component
public class JobHandler {
@XxlJob("orderTimeoutHandler")
public ReturnT<String> handleOrderTimeout() throws Exception {
// 查询超时未支付的订单
List<Order> orders = orderMapper.selectTimeoutOrders(30);
for (Order order : orders) {
order.setStatus(OrderStatus.CANCELLED);
orderMapper.updateById(order);
}
XxlJobHelper.log("处理了 {} 个超时订单", orders.size());
return ReturnT.SUCCESS;
}
// 分片广播任务(大数据量并行处理)
@XxlJob("syncDataHandler")
public ReturnT<String> handleSyncData() {
int shardIndex = XxlJobHelper.getShardIndex(); // 当前分片号
int shardTotal = XxlJobHelper.getShardTotal(); // 总分片数
// 按 ID 分片:每个分片处理一部分数据
List<Data> data = dataMapper.selectList(
new QueryWrapper<Data>()
.eq("id % " + shardTotal, shardIndex)
);
for (Data d : data) {
processData(d);
}
return ReturnT.SUCCESS;
}
}
3.4 路由策略
| 策略 | 说明 | 适用场景 |
|------|------|----------|
| 轮询 | 依次分发到每个执行器 | 负载均衡 |
| 随机 | 随机选一个执行器 | 负载均衡 |
| 一致性 HASH | 同一个任务总是路由到同一执行器 | 有状态任务 |
| 故障转移 | 优先到某个执行器,挂了自动切到备用 | 主备模式 |
| 分片广播 | 所有执行器都执行,传分片号 | 大数据量并行处理 |
3.5 分片广播场景
场景:每天凌晨同步 1000 万条商品库存
单机:处理 1000 万条数据,可能需要几个小时
分片广播:
部署 10 个执行器
分片号 0~9,每个执行器处理 100 万条
时间缩短 10 倍
分片逻辑:
每个执行器拿自己的分片号去取对应数据
如:id % 10 == shardIndex 的数据归这个分片处理
四、分库分表
4.1 为什么需要分库分表
MySQL 单表数据量过大时的性能问题:
100 万行:查询正常
1000 万行:索引变大,查询变慢
1 亿行:B+ 树高度增加,磁盘 I/O 暴增
经验值:
单表 500 万 ~ 1000 万行考虑分表
单库连接数过多(连接池打满)考虑分库
4.2 拆分方式
4.2.1 垂直分库
按业务拆分,不同业务放不同数据库
原来:
一个库:users + orders + goods + payments
拆分后:
user_db: users
order_db: orders
goods_db: goods
payment_db: payments
优势:
- 业务解耦
- 不同库可以独立扩容
- 权限隔离
问题:
- 跨库 JOIN 无法直接执行
- 分布式事务需要额外处理
4.2.2 水平分表
按数据行拆分,同一张表的数据分散到多个表中
原来:
orders 表:1 亿行
拆分后:
orders_0: id % 8 == 0 的数据
orders_1: id % 8 == 1 的数据
...
orders_7: id % 8 == 7 的数据
优势:
- 单表数据量减少,查询变快
- 索引变小
问题:
- 分页查询复杂(需要聚合多个表)
- 非分片字段查询需要全表扫描
- 自增 ID 不再连续(需要用雪花算法)
4.3 分片策略
| 策略 | 公式 | 适用场景 |
|------|------|----------|
| 取模 | id % n | 数据均匀分布 |
| 范围 | id 1-100万 -> 表0, 100万-200万 -> 表1 | 按时间归档 |
| 哈希 | hash(user_id) % n | 均匀散列 |
| 按日期 | YYYYMM | 日志、订单按月分表 |
4.4 分库分表带来的问题
| 问题 | 说明 | 解决方案 |
|------|------|----------|
| 跨库 JOIN | 不同库的表无法 JOIN | 应用层组装、冗余字段、宽表 |
| 分布式事务 | 跨库操作需要分布式事务 | Seata、本地消息表 |
| 分页查询 | 多表分页需要合并排序 | 限制页数、游标分页 |
| 全局排序 | ORDER BY 跨表无效 | 内存排序 |
| 唯一索引 | 分表后唯一约束失效 | 全局唯一 ID(雪花算法) |
| 扩容 | 增加分片需要迁移数据 | 一致性 Hash、双写迁移 |
4.5 中间件
| 中间件 | 类型 | 说明 |
|--------|------|------|
| ShardingSphere-JDBC | 客户端 | Jar 包形式,无代理层,性能好 |
| ShardingSphere-Proxy | 代理层 | 独立进程,对应用透明 |
| MyCat | 代理层 | 老牌中间件,功能全面 |
// ShardingSphere-JDBC 配置示例
spring:
shardingsphere:
rules:
sharding:
tables:
orders:
actual-data-nodes: ds.orders_${0..7}
table-strategy:
standard:
sharding-column: id
sharding-algorithm-name: order-inline
sharding-algorithms:
order-inline:
type: INLINE
props:
algorithm-expression: orders_${id % 8}
4.6 分库分表的最佳实践
1. 不要一开始就分库分表(过早优化)
2. 先从垂直拆分开始(按业务拆库)
3. 单表超过 500 万行再考虑水平分表
4. 分片字段选择高频查询字段(通常是 user_id 或 order_id)
5. 预留足够的分片数(如 8、16、32,避免频繁扩容)
6. 扩容方案:双写 -> 数据迁移 -> 切换读

浙公网安备 33010602011771号