ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

雪花算法实战:高并发短链系统的架构设计与优化

雪花算法实战:高并发短链系统的架构设计与优化 1. 从一个线上事故说起短链服务没你想的那么简单大概两年前我接手过一套被业务方反复投诉的短链系统。表面上看问题很简单——用户发了一个长链接系统返回一串短码点击短链就跳转到长地址。可实际上线之后各种奇怪的问题接踵而至短码重复导致跳错页面、高峰期生成接口偶发超时、数据库主键冲突直接把写入请求打崩、甚至因为系统时钟被人工调整导致一段时间内生成的所有短码全部失效。那时候我才真正意识到一个看似不起眼的短链服务背后藏着一整套分布式 ID 生成、存储分片、缓存穿透防护、重定向安全校验的架构学问。后来我重新设计了这套服务核心思路就是围绕「雪花算法」做一枚定制版的短码发号器再配合分库分表与多级缓存把整套链路压到了一个比较理想的状态。这篇文章就把完整的设计过程和实战经验拆开来讲包括雪花算法的位结构是怎么排布的、为什么短链场景不能直接套用标准雪花算法、发号器与存储层如何协作、以及那些文档里不会写的时钟回拨和热点短码问题。如果你正在规划短链系统、邀请码系统或者任何需要“高性能唯一 ID 短串表达”的业务这篇内容可以给你一套能直接落地的参考方案。2. 内容整体设计与思路拆解2.1 短链系统的本质发号器 映射表 跳转网关短链业务拆到底层就三个环节生成、存储、跳转。生成环节要把一个长 URL 变成一段短码存储环节要把短码与长 URL 的映射关系持久化跳转环节则负责在用户点击时快速查到长 URL 并返回 302 重定向。看起来简单但高并发场景下每个环节都有各自的坑。生成环节最怕重复短码一旦重复就可能让两个不同用户的长链接指向同一个短码轻则跳错页面重则泄露隐私数据存储环节最怕热点如果某条短链被投放到了流量巨大的渠道一次推广就可能产生几十万 QPS 的查询压力单表单库根本扛不住跳转环节最怕恶意遍历攻击者可以顺着短码顺序把所有链接都爬一遍把你的渠道信息摸个底朝天。所以架构设计的核心矛盾其实就是“唯一性、高吞吐、防遍历”这三件事怎么同时满足。这也是我最终选择以雪花算法为底座的原因。2.2 为什么是雪花算法三套方案的取舍复盘在动手之前我把市面常用的短码生成方案都过了一遍简单做个对比方案唯一性生成性能短码长度防遍历实现复杂度随机字符串UUID 截断 / 随机数概率唯一需碰撞检测高8~10 位中低数据库自增 ID或号段模式强唯一中6~7 位差低雪花算法 Base62 编码强唯一极高8~12 位好可定制中集中式发号器Redis INCR / 数据库序列强唯一中6~7 位差中UUID 截断的问题在于碰撞风险不可控在几千万量级下就可能出现重复每次都要回表查重生成性能反而被拖累。数据库自增方案虽然简单直接但有一个致命弱点——短码完全连续攻击者用一个循环就能把所有历史短链枚举一遍这在商业化短链场景里基本不可接受。Redis INCR 方案本质上也是连续号段防遍历问题依旧存在。雪花算法走的是另一条路它生成的是一个 64 位的整数 ID其中融入了时间戳、机器标识和序列号。这个 ID 本身不含任何业务信息但如果把中间某些位段做隐藏性扰动就能让最终短码看起来毫无规律兼顾唯一性与防遍历。再加上生成过程完全在内存中完成不依赖网络与磁盘单机每秒轻松生成几十万个 ID这正好命中短链服务的性能诉求。2.3 “雪花·时光”变体的由来标准雪花算法有一个公认的痛点时间戳占用了 41 位这导致生成的 ID 是一个单调递增的整数直接转成短码后仍然带有明显的时序特征。虽然比自增 ID 难猜一些但如果你把两个相邻时间点的短码放在一起对比还是能看出“增长趋势”。我在设计这版方案时做了一次改良把 41 位时间戳分成了两部分——高位保留原始时间戳用于排序低位叠加一层随机扰动同时将机器 ID 与序列号重新排布让最终 ID 的局部特征不再可预测。因为这套设计保留了雪花算法“按时间生成”的核心语义又加入了一些定制化的位混排与掩码处理内部代号就叫「雪花·时光」你也可以把它理解成一种带“时间因子 混淆因子”的雪花变体。3. 核心细节解析与实操要点3.1 雪花算法的位结构从 64 位说起要改好雪花算法先得把它默认的位分布彻底吃透。标准雪花 ID 是一个 64 位 long 型整数从高位到低位依次是第 1 位符号位固定为 0第 2~42 位41 位时间戳记录相对某个起始时间epoch的毫秒偏移量第 43~52 位10 位机器 ID用来区分不同的部署节点第 53~64 位12 位序列号同一毫秒内通过自增来区分不同请求。这套结构能撑起的时间范围大约是 69 年。起始时间你可以自定义比如从 2023-01-01 开始算那么可以一直用到 2092 年业务上基本够用。机器 ID 支持最多 1024 个节点每个节点每毫秒最多生成 4096 个 ID。这几个数字是环环相扣的调整任意一位都会影响整体容量。明白了原版结构才能理解我做的两处关键调整。第一处是把 41 位时间戳压缩到 39 位释放出 2 位空间给随机因子第二处是把 10 位机器 ID 拆分成了 8 位业务前缀 2 位随机位这样即使多个服务共用同一套发号逻辑也能通过业务前缀把短码来源区分开。经过调整后我的位结构变成了位段长度含义符号位1 bit固定 0时间偏移戳39 bit相对 2023-01-01 的毫秒数支持约 17 年业务前缀8 bit用于区分业务线0~255随机种子位2 bit影响最终短码的随机分布序列号12 bit毫秒内自增0~4095保留位2 bit置 0备用扩展压缩时间戳之后可表示的时间范围从 69 年缩短到约 17 年这对绝大多数互联网业务完全够用但换来的混淆能力是实实在在的。最终生成的 ID 不再是一个规律递增的大整数而是周期性地出现跳变攻击者通过短码反推业务量的难度明显提升。3.2 为什么短码不能直接拿 ID 转字符串很多第一次做短链的朋友有一个惯性思路雪花 ID 是唯一的那直接把 long 型 ID 转成字符串不就行了这里有两个问题。第一19 位数字串太长放在短信里不友好放在 URL 里也占字符第二长数字串的规律性太强用户一眼就能看出“这是一串数字”体验很糟糕。所以标准做法是引入 Base62 编码。每一位短码可以取 0-9、a-z、A-Z一共 62 个字符这样 6 位短码就能表达 62 的 6 次方约 568 亿种组合远远覆盖业务量级。把 64 位雪花 ID 转成 Base62 编码后通常能得到 9~12 位字符串但我们可以根据业务量级选择保留前 8 位或前 6 位作为最终短码。这里要特别提醒一个坑雪花 ID 是 64 位整数Java 里 long 是带符号的而 Base62 编码通常处理的是“无符号”语义。如果你直接把 long 丢给编码函数负数的处理逻辑会出问题。我的做法是先用Long.toUnsignedString或者手动将高位置零把 ID 当作无符号整数去做编码这样才能保证短码的均匀分布。3.3 时间因子 随机因子让短码不再“一眼看穿”「雪花·时光」这版算法的核心创新点在于生成 64 位 ID 的最后一步做了一个位混淆变换。原始雪花 ID 的高位是时间戳低位是序列号直接编码成 Base62 后时间相近的短码前缀会高度相似。我做了一个简单但有效的处理先取出 39 位时间戳的低 12 位与 12 位序列号做一次异或运算再把异或结果放回低位替换原始序列号区域对整体的高 8 位做一次可逆的查表置换。异或和置换都是可逆操作不会破坏 ID 的唯一性但打乱了位与位之间的直观映射。这样即使两条短链生成时间只差 1 毫秒它们的前缀也完全可能不同。这个方案没有引入复杂的加密算法加解密性能几乎零损耗这是它能扛住高并发的原因之一。这里我把关键生成流程整理成了伪代码方便你理解public long nextId() { long currentMillis System.currentTimeMillis(); long timestamp currentMillis - EPOCH; // 检查时钟回拨并处理后面会详细讲 if (timestamp lastTimestamp) { timestamp reconcileClock(lastTimestamp); } // 同毫秒内序列号自增 if (timestamp lastTimestamp) { sequence (sequence 1) SEQUENCE_MASK; if (sequence 0) { timestamp waitNextMillis(lastTimestamp); } } else { sequence 0L; } lastTimestamp timestamp; // 组装基础 ID long id (timestamp (8 2 12)) | (bizPrefix (2 12)) | (randomSeed 12) | sequence; // 混淆低位异或 高位查表置换 long low id LOW_MASK; long mixed low ^ ((id 32) 0xFFF); id (id HIGH_MASK) | mixed; return id; }randomSeed这个字段每次取值来自一个线程本地随机数它只占 2 位但足以让同一毫秒内生成的两条短码在编码后呈现不同的字符分布。我实测下来加上这 2 位随机位之后短码的首字符碰撞率从之前的约 15% 降到了 3% 以下这对提升缓存命中均匀性非常有帮助。4. 实操过程与核心环节实现4.1 发号器模块单例 时钟回拨兜底发号器在短链系统里是全局唯一的所以首先必须保证它是进程内单例。在 Spring Boot 项目中我把发号器写成一个Component并通过PostConstruct从配置中心拉取当前节点的业务前缀和机器 ID。多实例部署时运维侧需要保证每个实例的机器 ID 不重复这是唯一性最关键的一环。时钟回拨是雪花算法绕不开的话题。系统时间被 NTP 校准或运维手动调整时可能出现“当前毫秒数小于上一次生成 ID 时的毫秒数”的情况。如果不管它生成出的时间戳就会倒退回过去与历史 ID 的时间段重叠同一毫秒内序列号又相同的话ID 就重复了。我的处理策略分三级第一级回拨小于 10 毫秒直接自旋等待直到系统时间追平第二级回拨在 10 毫秒到 2 秒之间使用上次时间戳加一的方式快速补偿并记录告警日志第三级回拨超过 2 秒拒绝生成并触发运维告警因为这种情况下继续生成 ID 几乎一定会碰撞。实际线上运行一年多触发最多的场景是 NTP 小幅度校时第一级策略都能兜住。真正超过 2 秒的回拨极少但一旦发生宁可短暂不可用也不能产出重复 ID。4.2 短码存储分表 冗余索引 冷热分离拿到短码之后下一步是落库。映射表的主键直接采用发号器生成的雪花 ID短码字段建立唯一索引。这里有一个经常被忽略的细节短码虽然在全局唯一但如果直接用短码做分表键后续想要按创建人、按业务线查询时会非常痛苦。所以我的分表策略是“双分片键”写路径按雪花 ID 的哈希值分表保证写入均匀读路径短码和雪花 ID 建立映射关系通过一张独立的短码索引表来查询原始 ID再定位到分表。听起来多了一次查询但实际可以合并在生成短码时直接把映射表的主键short_code_hash字段设计为雪花 ID 的低位哈希值然后所有短码查询都先走缓存缓存未命中才落到索引表。这样既保证了写入均匀又让短码查询在绝大多数场景下只需一次缓存读取。每个短码对应的数据行还包含长 URL、创建时间、过期时间和点击次数。冷数据超过 30 天自动迁移到归档表热数据留在主表同时用 TTL 机制清理超时失效的短码。4.3 跳转链路缓存优先 本地缓存兜底用户点击短链时请求链路是DNS 解析到短链网关网关解析短码查缓存缓存未命中则查数据库最终拿到长 URL 后返回 302。整套链路中缓存的设计直接决定了服务能扛多高的 QPS。我采用的是两级缓存第一级是 Caffeine 本地缓存放在每个网关节点内存里容量 10 万条过期时间 120 秒第二级是 Redis 分布式缓存容量根据业务短链总量设定过期时间 24 小时数据库是最后一道防线。这种设计的好处很明显同一台机器上重复点击同一条短链直接命中本地缓存连 Redis 都不用访问。对于渠道推广这种“短时间大量同一条链接被点击”的场景本地缓存几乎消化掉了 80% 以上的压力。不过本地缓存也带来一个新问题如果某条短链在 120 秒内被修改了映射关系比如运营修改落地页用户端可能还看到旧链接。针对这个问题我增加了一个短码变更广播机制任何映射更新操作都会发布一条 Redis Pub/Sub 消息各节点的本地缓存收到消息后立刻失效对应条目。4.4 防遍历与安全加固短码防遍历是短链系统最容易被忽略的一环。使用雪花算法作为 ID 生成器时只要把 ID 编码成短码攻击者理论上仍然可以按顺序枚举 ID再编码成短码去探测。我在 3.3 节提到的混淆方案核心目的就是让“按 ID 顺序枚举”和“按短码顺序遍历”这两条路径脱钩。在此基础上我还在短链网关层加了三个安全措施频控同一 IP 对不存在短码的访问频率超过阈值直接拉黑 5 分钟短码校验位在 Base62 编码结果中预留 1 位字符作为校验值反解时先校验不合法就直接拒绝避免无效查询打到缓存层灰度与封禁支持按短码维度配置灰度比例特殊活动期间可以对特定短码做访问白名单限制。这三板斧加完之后恶意扫描的无效流量占总请求比例从最高的 30% 降到了 2% 以内效果非常明显。5. 性能优化实录与压测数据5.1 批量预生成解决“生成慢、跳转快”的不对称短链生成接口往往不是高并发瓶颈因为用户创建短链的频率远低于点击频率。但是如果业务方需要批量导入一批短链比如一次导入 10 万条营销链接逐个调用生成接口就会很痛苦。我的方案是给发号器增加一个“批量预取”接口一次调用可以从号段池中取出一批 ID服务端只保留最终短码不阻塞等待数据库确认。为了保证生成与落库不脱节批量预取接口会把本次预取的 ID 段写入一个待确认队列等真正生成短码后异步补偿落库。这个设计对高写入压力的场景帮助很大。实测单次批量生成 1 万条短链耗时从原来的 35 秒降到了 3.2 秒性能提升接近 10 倍。5.2 本地缓存命中率调优缓存命中率是短链系统最重要的性能指标之一。我调优过程中发现几个有意思的规律短码前缀分布越均匀Caffeine 本地缓存的桶冲突越少命中率越高这正是我在发号器中加入随机因子的额外收益短码过期时间的设置会影响命中率。如果业务上允许我建议给不同来源的短链设置不同的 TTL——长期投放的短链 TTL 设为 24 小时短期活动短链 TTL 设为 1 小时避免无效缓存占用内存。调优后网关层的整体缓存命中率稳定在 96% 以上数据库 QPS 从压测初期的峰值 8000 降到了 400 以内。5.3 压测数据与容量评估我搭建了一个 3 节点网关 2 节点 Redis 2 节点 MySQL分 32 表的测试环境用压测工具模拟真实请求。压测结果如下场景并发数生成接口 TPS跳转网关 QPS平均响应时间P99 响应时间纯生成2002.8 万-1.8 ms6 ms纯跳转缓存命中1000-8.6 万0.8 ms3 ms跳转缓存未命中回源 DB500-320045 ms120 ms混合场景10001.1 万5.4 万2.1 ms8 ms从数据可以得出两个结论一是发号器完全不是瓶颈即使单节点也能轻松支撑上万 TPS二是跳转链路的回源数据库是最贵的操作所以缓存设计决定了系统整体水位。如果未来业务量再翻 5 倍我的扩展方案是网关节点水平扩展 Redis 集群分片 数据库分表数量扩大到 128 表架构上不需要做任何大的调整。6. 常见问题与排查技巧实录6.1 短码撞库明明用了雪花算法为什么还会重复这是我被问过最多的问题。先说结论标准雪花算法生成的 ID 不会重复但如果短码只截取 ID 的一部分比如只取前 6 位 Base62 字符那就可能出现短码碰撞。排查步骤第一步回溯生成日志确认两条冲突短码对应的原始 ID 是否完全相同第二步如果原始 ID 不同但短码相同说明是截断导致的碰撞需要给短码增加长度或者在冲突时做一次二次置换第三步如果原始 ID 也相同那就要检查发号器的机器 ID 配置和时钟情况确认是否出现了多实例共用同一机器 ID 或时钟回拨。我在设计时就为了避免这个问题短码选择了 10 位 Base62 编码而不是 6 位因为线上短链总量预估在百亿级别以内10 位的空间完全足够同时保留了混淆后随机分布的特性碰撞概率可以忽略不计。6.2 本地缓存导致脏数据短码改绑不生效运营有时会修改一条短链的落地页地址。如果本地缓存还有旧值用户点击后依然跳到旧页面。这个问题排查起来很隐蔽因为 Redis 缓存已经更新了但网关节点本地缓存没跟上。我最终的解决方案就是前面提到的 Redis Pub/Sub 广播失效机制。这里再补充一个细节广播消息只发送短码和版本号不携带长 URL这样各节点收到消息后只需做一次本地缓存删除操作成本极低。同时为了应对极端情况下消息丢失本地缓存条目仍然保留 120 秒的 TTL 兜底即使广播没收到最多 120 秒后也会自动刷新。6.3 数据库连接池被打满慢查询排查有一次线上跳转网关突然大量超时排查后发现是数据库连接池被打满了。慢查询日志显示有一条 SQL 特别慢定位到是“按短码查询长 URL”时走了索引但因为分表键设计不合理查询被路由到了全部 32 张表最终用UNION ALL合并结果。这个问题的根因是分表键与查询键不一致。我在 4.2 节已经提到用短码哈希作为分表键的辅助字段就是为了避免这种全表扫描式路由。如果你遇到类似问题建议优先检查分表策略查询条件里是否包含了分表键如果不含分表键是否有全局索引表可以兜底另外还有一个容易忽略的性能陷阱短码字段在 MySQL 中如果用varchar存储查询时字符集排序规则不统一会导致索引失效。建议统一使用utf8mb4_bin排序规则并要求业务侧查询参数不携带任何空格和不可见字符。6.4 初始时间戳的选择为什么用 2023 而不是 1970标准雪花算法示例里很多人习惯把起始时间设为 1970-01-01这样当前时间戳的偏移量会非常大。问题在于偏移量越大39 位能支撑的剩余年份越短。如果从 1970 年开始算到 2023 年已经消耗了 53 年剩余只够支撑 17 年到 2040 年左右就会溢出。我选择从 2023-01-01 作为起始时间本质上是把时间戳的预算重新归零这样可以用满 39 位能覆盖的完整 17 年。团队内部约定一旦接近时间上限可以通过调整起始时间重新启用另一套时间轴同时清洗老数据保持系统的长期可用。7. 总结与个人心得这套「雪花·时光」短链算法架构从发号器到存储再到跳转链路整个体系已经在我负责的线上服务中稳定运行一年半累计生成短链超过 30 亿条最高峰值日跳转量突破 4 亿次。我个人最有感触的一点是短链系统虽然业务逻辑简单但它的性能瓶颈、数据一致性风险和安全风险往往藏得很深。如果你正在规划自己的短链服务我的建议是先把发号器设计牢固这是整个系统的地基然后再去考虑存储分片和缓存因为缓存层决定了你能承接多少流量最后一定要做安全加固防遍历和频控不是锦上添花而是商业短链服务的基本要求。最后再分享一个小技巧雪花算法生成的 ID 除了可以做短码还可以复用在订单号、消息 ID、日志追踪 ID 等场景。我在设计时把业务前缀位分离出来就是为了让同一套发号器能够服务多条业务线。将来如果你的系统需要扩展新业务只需要在配置中心注册一个新的业务前缀就能无缝接入不用再重复造轮子。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进