分布式通用方案:雪花算法、限流、定时任务与分库分表

分布式通用方案:雪花算法、限流、定时任务与分库分表

一、雪花算法(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. 扩容方案:双写 -> 数据迁移 -> 切换读
posted @ 2026-05-07 16:57  xzlrf  阅读(19)  评论(0)    收藏  举报