旧作补记

原始时间:约 2026 年 9 月,具体日期不详
本站补录:2026-10-12。

文中测试数据为原始测试记录,本次补录未重新压测。

1. 背景与设计目标

由于多种原因,消息可能延迟处理,例如,10:00 产生的消息在 10:10 才被消费,若使用消费时间生成消息号 messageId,其排序位置就会偏离原始消息时间。双机房部署下,两地消息顺序不一致更容易引起注意。

本方案以 Kafka 消息时间为基准生成 messageId,尽可能保证延迟消费后的消息列表在宏观上按时间有序。 秒内分配顺序和溢出处理可能产生局部偏移,因此不保证严格的逐条时间顺序,也不要求同一消息在两机房生成完全相同的 messageId。

存储与推送时效分别处理:通过业务过滤且需要存储的消息先写入 Redis,再判断是否允许推送。超过默认 15 分钟推送时限的消息仍可用于补齐列表,但不再实际推送。

2. 设计思路

概括:算法基于雪花算法“时间 + 节点号 + 序列号”的组合思路进行改进:采用 Kafka 消息时间 保持排序基准,采用 秒级状态缓存 控制内存开销,采用 步长 200 满足 Redis score 的精度约束。

正常分配时,编号由以下部分相加得到:

messageId = (消息所属秒 + 溢出偏移秒数) × 10,000,000
    + 节点号 × 1,000,000
    + 秒内序号 × 200
    + 621355968000000000

每秒的节点编号空间划分为 10 个节点区间,节点号为 0~9。每个节点占用 1,000,000 个数值单位,按步长 200 分配,可容纳 5000 个编号,序号范围为 0~4999。纪元常量和时间计算中的加 8 小时处理用于兼容历史 messageId 格式;无溢出时,偏移秒数为 0。

2.1 秒级粒度的选择

内存开销。 算法需要缓存全天各时间段的分配状态。按秒需要 86,400 个状态槽,按毫秒需要 86,400,000 个;采用相同结构时,毫秒级的槽存储开销约为秒级的 1000 倍。对于宏观时间保序的业务要求,秒级粒度能够以较低内存成本满足需求。

分配容量。 舍弃原始毫秒部分后,秒内空间统一用于节点区间和序号分配,避免容量分散在各毫秒内,更适合集中消费和突发流量。

2.2 步长 200 的选择

Redis ZSet 使用 double 保存排序分数。当前 messageId 数值较大,差值过小的两个 long 编号在转换后可能得到相同的 score。

当前 messageId 量级下,double 相邻可表示值的间距为 128。选择步长 200,可使正常分配的相邻编号转换为 score 后仍可区分,但不代表每个 messageId 都能被 double 精确表示。

舍弃毫秒和设置步长是两项配合的设计:前者用于组织容量、节省内存,后者直接解决步长过小导致 score 相同的问题。

3. 分配流程示例

算法为一天中的每一秒维护一个状态槽。消息根据时间戳定位所属秒槽,并在该槽中申请序号。

假设 10:00:00 对应的基础编号为 B,该节点这一秒尚未分配过:

第    1 条:B
第    2 条:B + 200
第    3 条:B + 400
……
第 5000 条:B + 999800
第 5001 条:转到 10:00:01 对应的秒槽继续分配

当前秒槽容量耗尽后,继续查找后续秒槽。借用后续秒仅涉及编号空间分配,无需等待实际时间推进。 溢出消息与原本属于目标秒的消息共用该槽计数,避免重复分配。

5000 表示单个秒槽的容量,不是实时处理吞吐量上限。溢出后 messageId 的时间部分会向后偏移,因此保序能力以宏观时间顺序为目标。

4. 关键代码解析

函数位于 MessageIdGenerator.nextIdBySecond。下面按执行顺序拆解;代码片段省略了部分注释和外围括号。

4.1 状态初始化与时间定位

private static final AtomicLong[] SECOND_STATE_SLOTS = new AtomicLong[24 * 60 * 60];
private static final int STEP = 200;
private static final int MAX_OFFSET = 1000000 / STEP;

static {
    for (int i = 0; i < SECOND_STATE_SLOTS.length; i++)
        SECOND_STATE_SLOTS[i] = new AtomicLong(0);
}

数组为全天各秒保存分配状态,通过 AtomicLong 支持并发条件更新。静态初始化执行一次,进程重启后状态清零。

final long mills = currentMills + 8 * 60 * 60 * 1000;
final long sec = mills / 1000;
final int dayIndex = (int) (sec / 86400);
final int secondOfDay = (int) (sec % 86400);

long packedSlot, newPackedSlot, targetSec = secondOfDay;
long storedDay, overflowSec, allocOffset;
int retryCount = 0;
AtomicLong offset;

currentMills 接收 Kafka 时间戳。加 8 小时后除以 1000,得到秒级时间,毫秒部分被舍弃。

dayIndex 表示第几天,secondOfDay 表示当天第几秒。例如 10:00:03 对应索引 36003。每天都会复用这个位置,所以必须记录日期,才能判断计数是否需要重置。

targetSec 是当前分配位置,初始值为 secondOfDay,容量耗尽后向后移动。packedSlot 和 newPackedSlot 分别保存读取状态与候选更新状态;storedDay、overflowSec、allocOffset 分别表示槽日期、跳转位置和待分配序号。

4.2 状态读取与字段拆分

offset = SECOND_STATE_SLOTS[(int) targetSec];
packedSlot = offset.get();
storedDay = (packedSlot >>> 40) & 0xFFFFFF;
overflowSec = (packedSlot >>> 20) & 0xFFFFF;
allocOffset = packedSlot & 0xFFFFF;

每个 long 按 24/20/20 位布局保存三个字段:

高 24 位:日期 | 中间 20 位:溢出跳转位置 | 低 20 位:待分配序号

>>> 将需要的部分右移,& 只保留指定的位。例如提取日期时,先右移 40 位,再保留 24 位。三个字段都来自同一次读取,后面也可以一次性更新,避免日期和计数分别修改造成状态不一致。

overflowSec=0 表示未建立跳转。allocOffset=7 表示序号 0~6 已分配,本次尝试分配序号 7。

4.3 日期比较与状态重置

if (storedDay > dayIndex) {
    return sec * 10000000 + nodeId * 1000000 + 621355968000000000L;
}
if (storedDay < dayIndex) {
    storedDay = dayIndex;
    overflowSec = allocOffset = 0;
    newPackedSlot = (storedDay << 40) + 1;
}

如果槽里已经是更新的日期,例如槽属于 9 月 15 日,消息却来自 9 月 14 日,代码直接返回不带序号偏移的 messageId。这个分支会放弃唯一性,同秒同节点可能重复。

如果消息日期更新,则清除旧计数和跳转,从序号 0 开始。storedDay « 40 将日期写入高位,+1 将下一待分配序号设为 1。

本次使用序号 0,候选状态记录下一序号 1。 此处仅构造新状态,后续 CAS 成功后才确认分配。

4.4 同日分配与溢出跳转

else if (overflowSec == 0) {
    if (allocOffset >= MAX_OFFSET) {
        overflowSec = targetSec + 1;
        newPackedSlot = packedSlot | (overflowSec << 20);
    } else {
        newPackedSlot = packedSlot + 1;
    }
} else newPackedSlot = 0;

当前槽容量未满时,候选计数加 1。例如读取值为 7,本次使用 7,更新值为 8。正常计数不超过 5000,加法不会进位到日期或跳转字段。

达到 5000 时,0~4999 已用完,就把下一个槽的位置写入中间 20 位。| 将跳转信息合入原状态,保留日期和计数。此操作只建立跳转,仍需进入目标槽完成序号分配。

如果已经有跳转,就不再修改当前槽。newPackedSlot=0 表示跳过本轮更新,沿现有指向继续找,不是把数组状态清零。

4.5 CAS 更新与循环控制

} while ((newPackedSlot > 0 && !offset.compareAndSet(packedSlot, newPackedSlot)) ||
        (overflowSec > 0 && (targetSec = overflowSec) < SECOND_STATE_SLOTS.length
                && retryCount++ < 16));

compareAndSet,简称 CAS,意思是:只有槽里的状态仍等于刚才读到的值,才写入新值。 如果已被其他线程修改,就失败并重试。

例如两个线程同时读取计数 7,并尝试更新为 8,只有一个线程能够成功分配序号 7;另一个重新读取状态后,继续申请后续序号。

这段循环有两个继续条件:更新失败,就留在当前槽重新读;存在跳转,就移动到后面的槽继续找。左侧更新失败时,右侧不会执行,所以 retryCount 只限制溢出跳转,不限制所有 CAS 重试。

目标到达 86400,或跳转计数达到限制时,循环也会退出。这两种退出不代表已成功分配序号,属于后文列出的边界情况。

4.6 溢出偏移与跳转路径优化

long secOffset = targetSec - secondOfDay;
if (retryCount > 2 && secOffset > 1) {
    packedSlot = SECOND_STATE_SLOTS[secondOfDay].get();
    overflowSec = (packedSlot>>> 20) & 0xFFFFF;
    if (overflowSec < targetSec) {
        SECOND_STATE_SLOTS[secondOfDay].compareAndSet(
                packedSlot,
                ((long) dayIndex << 40) | (targetSec << 20) | MAX_OFFSET);
    }
}

secOffset 表示目标秒相对原消息秒的偏移。例如目标从 10:00:00 移至 10:00:03,偏移为 3 秒。

若经过多个满槽,就尝试把原槽直接指向较远的位置:

原来:10:00:00 → 10:00:01 → 10:00:02 → 10:00:03
优化:10:00:00 → 10:00:03

候选状态保留本次日期、更新直达位置,并将原槽计数标记为已满。此处仅优化后续查找路径,CAS 失败无需重试,正常情况下不影响已分配编号。

4.7 messageId 组合与返回

return (sec + secOffset) * 10000000
        + nodeId * 1000000
        + STEP * allocOffset
        + 621355968000000000L;

返回值由目标秒、节点区间、序号偏移和纪元常量组成。例如节点 2 分配序号 7,对应的秒内偏移为 2,000,000 + 7 × 200。

无溢出时使用原消息秒,溢出时增加 secOffset。完整流程为:定位秒槽 → 原子分配序号 → 必要时向后溢出 → 组合 messageId。

5. 实现边界

宏观保序,不等于逐条编号一致。 同秒消息在两个机房的处理顺序或节点号不同,messageId 就可能不同。借用后续秒也会带来局部偏移,不能从 messageId 还原精确产生时间。

唯一性依赖进程状态与节点配置。 状态保存在进程内。旧日期消息命中已被新日期复用的槽、同节点进程重启回放,或者多个进程使用相同节点号,都可能产生重复编号。节点号 0~9 的范围也依赖配置,函数未主动校验。

极端溢出不保证正常分配。 最后一秒用满或跳转次数达到限制时,可能在未完成目标槽分配时直接返回。路径优化也没有再次校验原槽日期,跨日并发回放需注意。当前实现并未解决这些边界。

存储保证受业务规则与写入结果约束。 消息仍需通过业务过滤、启用存储并成功写入 Redis;编号函数不负责写入失败后的重试和补偿。

源码依据:编号生成函数与消息存储、分发流程。

6. 测试情况

测试内容包括运行槽位分析、APP 日志编号分析和多线程生成性能测试。

6.1 运行槽位分析

将监控快照中的 86,400 个槽位按日期、分配数量和溢出指针解码。槽位按日期逐项复用,因此快照中的分配数量是各槽位当前保留量,不能直接当作某一天的完整处理量。

检查非空槽、满槽、溢出槽以及后续首个未满槽,并完整追踪跳转链。单个指针跨度、整条链的时间跨度与跳转次数分别统计;链的时间跨度不等于循环次数。

原始检查中,全部槽位计数均不超过 5,000,溢出指针均向后且在数组范围内。具体生产日期、流量统计和业务时段不在本文展示。

6.2 日志消息编号分析

按编号统计出现次数、节点号、步长对齐情况,再与监控快照的槽位分配范围逐秒比较,检查是否存在超出分配范围的编号。

分时日志按写入时间归档,编号却按消息时间生成。因此一个日志文件可能缺少边界附近的部分编号,同时包含相邻时间区间的编号。核对相邻文件后,才能区分日志切割差异与编号生成异常。

原始检查中的重复记录来自同一次推送的多个处理阶段:开始、处理中与完成都携带同一编号。日志重复记录不等于编号重复生成,应先按一次处理流程归并,再判断生成器是否重复分配。

6.3 多线程生成性能测试

测试环境为 Intel Core i7-11700F、16 个逻辑核、Windows、JDK 1.8.0_151。直接调用 MessageIdGenerator.nextIdBySecond,固定节点编号为 1。

每组预热 200 万次后测试 3 轮,每轮约 1,000 万次,共完成 72 轮、720,002,400 次生成。每个线程使用独立预分配的 long[] 记录 ID;线程创建和数组分配在计时前完成,合并、排序、校验及写盘在计时后完成。计时区间内 GC 次数为 0。

场景时间戳分配方式
完全无秒槽竞争每个线程独占不同秒槽,每槽最多生成 4,000 个 ID,无溢出
共享 64 个起始秒所有线程轮转访问 64 个起始秒
共享 8 个起始秒所有线程轮转访问 8 个起始秒
共享同一秒所有线程使用同一个起始时间戳

共享场景的起始秒间隔为 512 秒,用于隔离不同热点的溢出区间。

6.3.1 生成吞吐

单位为 百万 ID/秒,取三轮中位数,统计生成调用与线程内数组写入的整体吞吐。

线程数完全无秒槽竞争共享 64 个起始秒共享 8 个起始秒共享同一秒
10572.39143.4357.4212.45
50703.92195.8249.2111.62
100585.60183.8147.2011.92
300490.76171.7348.1611.79
500474.13171.8449.4711.45
1000258.11132.5448.2411.52

完全无竞争时,吞吐为 2.58~7.04 亿 ID/秒;共享同一秒时,吞吐为 1,145~1,245 万 ID/秒。热点越集中,吞吐下降越明显。以上为固定工作量的短批次测量,无竞争档单轮耗时约 14~41 毫秒。

6.3.2 编号校验

以下为每组三轮累计的 多余重复次数 / 节点越界条数,各轮独立校验。

线程数完全无秒槽竞争共享 64 个起始秒共享 8 个起始秒共享同一秒
100 / 00 / 00 / 00 / 0
500 / 00 / 751 / 389612 / 2,073
1000 / 00 / 3578 / 259618 / 1,997
3000 / 00 / 00 / 0670 / 1,359
5000 / 00 / 00 / 00 / 4
10000 / 00 / 00 / 00 / 6

测试结果符合预期。完全无秒槽竞争的各组均无重复和节点越界。共享场景触达跳转上限时,累计出现 2,029 次多余重复、6,129 条节点越界记录,符合已接受的边界行为。步长校验通过,未发现不同整数 ID 转为 Redis double 后的额外碰撞。全部生成记录按线程独立保存,落盘数据复核结果与压测统计一致。

6.4 测试总结

原始测试包括运行槽位检查、客户端日志编号分析,以及 10~1000 线程、约 7.2 亿次生成的测试。实际日志中的重复项已确认为同一次推送的多阶段记录,小时边界差异由日志切割造成,未发现实际样本中的编号生成异常。

短批次压测中,完全无秒槽竞争时吞吐约 2.58~7.04 亿 ID/秒,共享同一秒时约 1,145~1,245 万 ID/秒。无竞争场景编号校验通过,共享场景触达跳转上限后的表现符合已接受的边界行为。

整体测试结果符合预期。