号段缓存

性能瓶颈的真相

我以为 Redis INCR 已经足够快了,直到那次压力测试。

那是周二的下午,我正在做 10 万 QPS 的压测。短链接服务的各项指标都很好——响应时间在 5ms 以内,CPU 和内存也正常。但 Redis 的监控曲线让我皱起了眉:

Redis QPS: 98,000/s
Redis 延迟: P99 从 1ms 飙升到 15ms

问题很明显:每次生成都要走一次 Redis。在高并发时,网络往返成了最大的开销。即使 Redis 本身够快,但每次调用都要:

  1. 序列化请求
  2. 发送到 Redis
  3. Redis 执行 INCR
  4. 序列化响应
  5. 返回给应用

这 5 步加起来,单次可能只要 0.5ms,但乘上每秒 10 万次,就成了巨大的负担。

更重要的是,这让我的系统高度依赖 Redis。如果 Redis 抖一下,整个 ID 生成都受影响。

我需要减少对 Redis 的依赖。

号段缓存的思想

我开始研究业界的方案,看到了美团技术团队的 Leaf 项目。他们的思路让我眼前一亮:

不是每次取一个 ID,而是一次取一批。

就像我去超市买东西——不是每需要一件东西就跑一趟超市,而是一次买一周的量放在家里慢慢用。

传统模式(每次 1 个 ID):
  我 → Redis: "给我 1 个 ID" → 1
  我 → Redis: "给我 1 个 ID" → 2
  我 → Redis: "给我 1 个 ID" → 3
  ...(每次都要网络往返)

号段模式(每次取一批):
  我 → Redis: "给我 1000 个 ID" → 1~1000
  我在本地分配: 1, 2, 3, ..., 1000
  用完后 → Redis: "再给我 1000 个" → 1001~2000

这样的好处显而易见:

指标传统模式号段模式
Redis 请求次数每生成 1 次每 1000 个 1 次
Redis 压力减少 1000 倍
ID 生成速度受限于网络纯内存操作
外部依赖每次都依赖可短暂独立运行

99% 的 ID 生成都变成了纯内存操作,性能提升会是数量级的。

实现号段分配器

我决定实现一个号段分配器。先用最简单的方案——单号段。

数据库设计

我把 Redis 换成了数据库,因为数据库更可靠,而且号段模式下请求次数极少:

CREATE TABLE id_segment (
    biz_tag VARCHAR(64) PRIMARY KEY COMMENT '业务标签',
    max_id BIGINT NOT NULL COMMENT '当前最大 ID(已分配出去的)',
    step INT NOT NULL COMMENT '号段长度',
    version BIGINT NOT NULL COMMENT '版本号(乐观锁)',
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 初始化
INSERT INTO id_segment (biz_tag, max_id, step, version)
VALUES ('url_shortener', 0, 1000, 0);

这里的关键是 max_id 字段。它表示”已经分配出去的最大 ID”,所以下一个号段从 max_id + 1 开始。

第一版:单号段实现

import threading
import time

class SegmentIDGenerator:
    """号段模式 ID 生成器 - 第一版"""

    def __init__(self, db_connection, biz_tag='url_shortener', step=1000):
        self.db = db_connection
        self.biz_tag = biz_tag
        self.step = step

        # 当前号段
        self.current_id = 0
        self.max_id = 0

        self.lock = threading.Lock()
        self._load_segment()

    def next_id(self):
        """获取下一个 ID"""
        with self.lock:
            # 当前号段用完了,去数据库取新的
            if self.current_id >= self.max_id:
                self._load_segment()

            self.current_id += 1
            return self.current_id

    def _load_segment(self):
        """从数据库加载一个新号段"""
        # 乐观锁更新:UPDATE ... WHERE version = old_version
        while True:
            row = self.db.query(
                "SELECT max_id, step, version FROM id_segment WHERE biz_tag = ?",
                self.biz_tag
            )

            old_max = row['max_id']
            step = row['step']
            version = row['version']
            new_max = old_max + step

            # CAS 更新(Compare And Swap)
            affected = self.db.execute(
                """UPDATE id_segment
                   SET max_id = ?, version = version + 1
                   WHERE biz_tag = ? AND version = ?""",
                (new_max, self.biz_tag, version)
            )

            if affected > 0:
                # 成功抢到号段
                self.current_id = old_max
                self.max_id = new_max
                print(f"📦 加载号段:{old_max + 1} ~ {new_max}")
                break
            else:
                # 被其他实例抢先了,重试
                print("⚠️ 号段竞争冲突,重试...")
                time.sleep(0.01)

    def execute(self, sql, params):
        """执行更新语句"""
        return self.db.execute(sql, params)

这个实现的核心是 乐观锁。通过 version 字段保证多个实例同时请求时,只有一个能成功抢到号段:

UPDATE id_segment
SET max_id = max_id + step, version = version + 1
WHERE biz_tag = 'url_shortener' AND version = ?  -- 只有 version 匹配才能更新

如果 affected 是 0,说明被别人抢先了,重新读取并重试。

性能测试

我跑了压测,结果让我兴奋:

单号段模式性能:
- QPS: 95,000/s(纯内存操作)
- 数据库请求:约 95 次/秒(每 1000 个 ID 一次)
- 响应时间 P99: 2ms

对比之前的 Redis 方案:
- QPS: 70,000/s
- Redis 请求:70,000 次/秒
- 响应时间 P99: 15ms

性能提升了约 35%,数据库压力减少了 99%!

更重要的是,系统更加稳定了。因为 99% 的 ID 生成是纯内存操作,不再依赖外部系统。

号段用完时的惊险

但第一版上线后,我很快发现了问题。

有次查看监控,我发现每隔几分钟就会有 短暂的延迟尖刺

正常请求:2ms
尖刺时刻:120ms, 85ms, 150ms...

我查看日志,发现这些尖刺都出现在 号段用完、重新加载数据库 的时候:

def next_id(self):
    with self.lock:
        if self.current_id >= self.max_id:
            # 🔴 同步去数据库取新号段,会阻塞所有线程
            self._load_segment()
        
        self.current_id += 1
        return self.current_id

在高并发时,号段用完的瞬间,所有线程都会被阻塞,等待数据库返回。虽然只有几十毫秒,但在高并发系统中,这就是一个明显的延迟尖刺。

双 Buffer 方案

我想到一个解决方案:双缓冲

就像视频播放器的预加载——不是等到当前视频播完了再加载下一个,而是在播放到 80% 时就开始预加载下一个。

import threading
import time

class DoubleBufferSegmentGenerator:
    """双缓冲号段模式 ID 生成器"""

    def __init__(self, db_connection, biz_tag='url_shortener', step=1000):
        self.db = db_connection
        self.biz_tag = biz_tag
        self.step = step
        self.lock = threading.Lock()

        # 当前号段(正在使用)
        self.current_id = 0
        self.current_max = 0

        # 下一个号段(预加载)
        self.next_current = 0
        self.next_max = 0
        self.next_ready = False
        self.loading = False

        # 初始加载
        self._load_segment()

    def next_id(self):
        """获取下一个 ID"""
        with self.lock:
            self.current_id += 1

            # 当前号段用完
            if self.current_id > self.current_max:
                # 检查下一个号段是否准备好了
                if self.next_ready:
                    self._switch_to_next()
                else:
                    # 没准备好,只能同步加载(降级)
                    print("⚠️ 下一个号段还没准备好,同步加载中...")
                    self._load_segment()

            # 触发预加载(剩余不到 20% 时)
            if self._should_preload() and not self.loading:
                self._async_preload()

            return self.current_id

    def _should_preload(self):
        """判断是否需要预加载(剩余不到 20% 时)"""
        total = self.step
        used = self.current_id - (self.current_max - self.step)
        return used >= total * 0.8

    def _async_preload(self):
        """异步预加载下一个号段"""
        self.loading = True
        thread = threading.Thread(target=self._preload_worker, daemon=True)
        thread.start()

    def _preload_worker(self):
        """预加载线程"""
        try:
            self._load_next_segment()
            self.next_ready = True
            print(f"✅ 下一号段预加载完成:{self.next_current + 1} ~ {self.next_max}")
        finally:
            self.loading = False

    def _load_next_segment(self):
        """加载下一个号段到缓冲区"""
        while True:
            row = self.db.query(
                "SELECT max_id, step, version FROM id_segment WHERE biz_tag = ?",
                self.biz_tag
            )
            old_max = row['max_id']
            step = row['step']
            version = row['version']
            new_max = old_max + step

            affected = self.db.execute(
                """UPDATE id_segment
                   SET max_id = ?, version = version + 1
                   WHERE biz_tag = ? AND version = ?""",
                (new_max, self.biz_tag, version)
            )

            if affected > 0:
                self.next_current = old_max
                self.next_max = new_max
                break
            time.sleep(0.01)

    def _switch_to_next(self):
        """切换到预加载的号段"""
        self.current_id = self.next_current
        self.current_max = self.next_max
        self.next_ready = False

    def _load_segment(self):
        """同步加载号段"""
        while True:
            row = self.db.query(
                "SELECT max_id, step, version FROM id_segment WHERE biz_tag = ?",
                self.biz_tag
            )
            old_max = row['max_id']
            step = row['step']
            version = row['version']
            new_max = old_max + step

            affected = self.db.execute(
                """UPDATE id_segment
                   SET max_id = ?, version = version + 1
                   WHERE biz_tag = ? AND version = ?""",
                (new_max, self.biz_tag, version)
            )

            if affected > 0:
                self.current_id = old_max
                self.current_max = new_max
                break
            time.sleep(0.01)

双 Buffer 的工作流程

时间轴:
  ┌─── 号段1(1~1000)────┐   ┌─── 号段2(1001~2000)───┐
  │                        │   │                          │
  │ 用到 80% 时触发预加载  │→  │ 异步从数据库获取          │
  │                        │   │                          │
  │ 继续使用到 100%        │→  │ 切换!无缝衔接 ✅         │
  └────────────────────────┘   └──────────────────────────┘

                                用到 80% 时预加载号段3...

再跑压测,延迟尖刺消失了:

双缓冲模式性能:
- QPS: 98,000/s
- 响应时间 P99: 2ms(无尖刺)
- 数据库请求:约 98 次/秒

完美!延迟稳定,性能进一步提升。

多实例部署

号段模式天然支持多实例部署。每台服务器会取到不同的号段,不会冲突:

服务器 A:取到 1~1000
服务器 B:取到 1001~2000
服务器 C:取到 2001~3000

各用各的号段,完全不冲突 ✅

生成的 ID 序列:

实际生成的 ID 顺序:
A: 1, 2, 3, ...         ← A 的号段
B: 1001, 1002, 1003, ... ← B 的号段
C: 2001, 2002, ...       ← C 的号段
A: 4, 5, ...             ← A 继续

总体:1, 2, 3, 1001, 1002, 4, 5, 2001, ...
→ 不是严格递增,但趋势递增 ✅

Step 大小选择

号段的 step 大小需要权衡:

  • 太小:频繁请求数据库,失去缓存的意义
  • 太大:浪费 ID,重启后丢失大量 ID

我根据业务量计算最优值:

def calculate_optimal_step(daily_creation, num_instances):
    """计算最优 step 大小"""
    # 目标:每个号段用 5-10 分钟
    ids_per_minute = daily_creation / (24 * 60)
    ids_per_instance_per_minute = ids_per_minute / num_instances

    # 每个号段用 5 分钟
    step = int(ids_per_instance_per_minute * 5)

    # 至少 100,最多 100 万
    step = max(100, min(step, 1_000_000))

    return step

# 示例:每天 500 万创建量,10 台服务器
step = calculate_optimal_step(5_000_000, 10)
print(f"推荐 step: {step}")
# 输出:推荐 step: 1736(约 2000)

最终方案

经过这些优化,我的分布式 ID 方案已经相当成熟了。但我想再进一步——如果数据库挂了呢?

虽然号段模式下数据库请求极少,但如果真的挂了,系统还是不能生成 ID。所以我加了一层保险:

号段缓存 + Snowflake fallback:

1. 优先使用号段缓存(短 ID,性能高)
2. 如果号段用完且数据库不可用,降级到 Snowflake(长 ID,但可用)
3. 监控告警,尽快恢复数据库

这样,即使数据库挂了,系统依然可以生成 ID,只是 ID 会变长,不再是连续的。

监控与告警

我加上了监控指标:

class MonitoredSegmentGenerator(DoubleBufferSegmentGenerator):
    """带监控的号段生成器"""

    def next_id(self):
        id_val = super().next_id()

        # 上报监控指标
        remaining = self.current_max - self.current_id
        total = self.step
        usage = (total - remaining) / total * 100

        metrics.gauge("segment.remaining", remaining)
        metrics.gauge("segment.usage_percent", usage)

        if remaining < total * 0.1:
            alert(f"⚠️ 号段即将用完!剩余 {remaining} 个 ID")

        return id_val

监控仪表盘:

┌─────────────────────────────────────────┐
│ 号段模式监控                             │
├─────────────────────────────────────────┤
│ 当前号段:12,001 ~ 13,000               │
│ 已使用:78% ████████████░░░             │
│ 剩余:274 个                             │
│ 预加载状态:✅ 下一号段已就绪             │
│ 数据库请求频率:约 1 次/5 分钟            │
│ ID 生成速度:约 200 个/秒                │
└─────────────────────────────────────────┘

想一想

  1. 号段模式下,如果一台服务器拿到号段后挂了,那些 ID 会怎样?(答案:丢失了,但不影响唯一性)
  2. 为什么说号段模式生成的 ID 是”趋势递增”而不是”严格递增”?
  3. 如果号段的 step 设太小(如 10),会有什么问题?设太大(如 1000 万)呢?

方案对比

特性数据库自增Redis INCR号段模式雪花算法
唯一性
递增严格严格趋势趋势
数据库压力极低
外部依赖数据库Redis数据库
性能极高极高
实现复杂度
ID 连续性连续连续段内连续不连续