旧作补记
原始时间:约 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 个起始秒 | 共享同一秒 |
|---|---|---|---|---|
| 10 | 572.39 | 143.43 | 57.42 | 12.45 |
| 50 | 703.92 | 195.82 | 49.21 | 11.62 |
| 100 | 585.60 | 183.81 | 47.20 | 11.92 |
| 300 | 490.76 | 171.73 | 48.16 | 11.79 |
| 500 | 474.13 | 171.84 | 49.47 | 11.45 |
| 1000 | 258.11 | 132.54 | 48.24 | 11.52 |
完全无竞争时,吞吐为 2.58~7.04 亿 ID/秒;共享同一秒时,吞吐为 1,145~1,245 万 ID/秒。热点越集中,吞吐下降越明显。以上为固定工作量的短批次测量,无竞争档单轮耗时约 14~41 毫秒。
6.3.2 编号校验
以下为每组三轮累计的 多余重复次数 / 节点越界条数,各轮独立校验。
| 线程数 | 完全无秒槽竞争 | 共享 64 个起始秒 | 共享 8 个起始秒 | 共享同一秒 |
|---|---|---|---|---|
| 10 | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 |
| 50 | 0 / 0 | 0 / 7 | 51 / 389 | 612 / 2,073 |
| 100 | 0 / 0 | 0 / 35 | 78 / 259 | 618 / 1,997 |
| 300 | 0 / 0 | 0 / 0 | 0 / 0 | 670 / 1,359 |
| 500 | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 4 |
| 1000 | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 6 |
测试结果符合预期。完全无秒槽竞争的各组均无重复和节点越界。共享场景触达跳转上限时,累计出现 2,029 次多余重复、6,129 条节点越界记录,符合已接受的边界行为。步长校验通过,未发现不同整数 ID 转为 Redis double 后的额外碰撞。全部生成记录按线程独立保存,落盘数据复核结果与压测统计一致。
6.4 测试总结
原始测试包括运行槽位检查、客户端日志编号分析,以及 10~1000 线程、约 7.2 亿次生成的测试。实际日志中的重复项已确认为同一次推送的多阶段记录,小时边界差异由日志切割造成,未发现实际样本中的编号生成异常。
短批次压测中,完全无秒槽竞争时吞吐约 2.58~7.04 亿 ID/秒,共享同一秒时约 1,145~1,245 万 ID/秒。无竞争场景编号校验通过,共享场景触达跳转上限后的表现符合已接受的边界行为。
整体测试结果符合预期。
评论
欢迎交流。评论通过 GitHub 账号登录,保存在本博客的 Discussions 中。