ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从毫秒时间戳到分布式唯一ID:雪花算法实战复盘

从毫秒时间戳到分布式唯一ID:雪花算法实战复盘 1769226012701。老实说第一次在监控面板上看到这串数字我以为是哪台设备随手打出的乱码。直到我把它丢进时间戳转换工具界面弹出2026-01-24 11:40:12.701才意识到这是一条标准的毫秒级 Unix 时间戳。你可能在订单号、日志 traceId、消息队列 messageId 里见过类似的长串数字但不是每个人都有空停下来想它为什么是 13 位为什么能拿来当项目编号这篇就用这个数字当线索复盘我最近做的一个“时间戳型 ID 生成与解析”小项目解决什么问题、为什么不用 UUID、怎么落地以及上线后踩过的几个坑。如果你也是后端、全栈或者每天要跟日志打交道的同学看完应该能少走点弯路。1. 1769226012701 是什么先把它翻译成人话1.1 一眼识别毫秒级时间戳Unix 时间戳表示从 1970-01-01 00:00:00 UTC 到当前时刻经过的秒数。很多命令行工具默认输出 10 位秒级时间戳比如date %s会得到1769226012。但编程语言里常见的System.currentTimeMillis()、Date.now()、time.time() * 1000返回的是毫秒所以秒后面多了三位小数变成了 13 位。看到 1769226012701 这种数第一步不要急着搜代码先做换算。Python 里这样解from datetime import datetime, timezone, timedelta ts_ms 1769226012701 ts_s ts_ms / 1000 utc_dt datetime.fromtimestamp(ts_s, tztimezone.utc) cst_dt utc_dt.astimezone(timezone(timedelta(hours8))) print(utc_dt.isoformat()) # 2026-01-24T03:40:12.70100000:00 print(cst_dt.strftime(%Y-%m-%d %H:%M:%S.%f)) # 2026-01-24 11:40:12.701000用 JavaScript 也是一行事const ts 1769226012701; console.log(new Date(ts).toISOString()); // 2026-01-24T03:40:12.701Z console.log(new Date(ts).toString()); // Sat Jan 24 2026 11:40:12 GMT0800 (China Standard Time)注意一个关键点相同的数字如果按秒解析和按毫秒解析结果完全不一样。10 位的是秒13 位的是毫秒如果工具里把 13 位直接当秒传进去大概率会得到一个不知道是哪一年的离谱日期。1.2 13位数字在系统里的隐藏身份这种毫秒时间戳在真实项目里其实不止“时间”一个身份。我见过它出现在三个地方日志时间字段部分日志系统为了省空间不存yyyy-MM-dd HH:mm:ss.SSS而是直接记timestamp数字。请求 ID / traceId有些人图省事直接用System.currentTimeMillis()当请求唯一编号压测时非常容易撞车。组合 ID 的时间部分更成熟的做法是把它放在一个长 ID 的高位后面拼上机器号和自增序列比如类雪花算法的前 41 位。所以排错的时候不要拿着 13 位数字直接在日志里全文搜。更好的习惯是先转成时间确定大概窗口再在日志系统里按时间范围过滤否则很可能搜到一个不成串的数字反而把问题带偏。1.3 这个数字背后的小项目我并不是随机截取这个数字。它原本是我负责的一个消息处理组件里用System.currentTimeMillis()生成的 sessionId。当时为了少存一个 “创建时间” 字段我直接把毫秒时间戳当主键用。单测、小流量联调都没问题一到压测环境并发稍微上来数据库就开始报 duplicate key。日志里那串 1769226012701 就是某个线程在 2026-01-24 11:40:12.701 这一毫秒里拿到的原始时间戳。这次故障让我下定决心把“裸时间戳”改成一套完整的 ID 生成方案。2. 核心设计思路时间戳到底能不能当项目 ID2.1 为什么第一反应不是 UUID很多人遇到“全局唯一 ID”需求时第一反应是UUID.randomUUID().toString()。这确实是最省事的方案但我在这个项目里没有选它。原因不是 UUID 不唯一而是它在数据库索引上的表现太难看无序、36 位、插入时随机 IO。数据量小无所谓数据量上来之后B 树页分裂、写放大、索引膨胀都会变成实打实的成本。时间戳则自带趋势递增属性新产生的 ID 理论上比老 ID 大数据库插入时更可能命中热页索引维护也友好得多。另外一个额外收益是ID 本身能反解出时间查问题的时候看一眼 ID 的高位就知道大概发生时间不需要再 join 另一张表。2.2 时间戳 ID 的四个优点可排序时间戳高位让 ID 在存储层面基本有序分页查询、按时间倒序都很方便。可反解把 ID 右移 N 位或者直接解析高位能得到创建时间适合做链路追踪和耗时统计。无中心依赖不像数据库自增 ID 依赖单点数据库也不像 Redis INCR 依赖 Redis 高可用纯本机时间 本机参数就能生成非常适合微服务多节点场景。组合灵活时间戳只是前缀后面可以接机器 ID、进程 ID、随机数、序列号你想塞多少维度都能塞进去。2.3 裸时间戳不能直接上生产的三个原因但直接用 1769226012701 这种裸时间戳做 ID坑非常明显同毫秒并发冲突一个高并发接口在 1ms 内可能处理几百上千个请求两个线程在同一毫秒拿到相同时间戳ID 就重复了。时钟回拨服务器 NTP 同步偶尔会把本机时间往回拨一小段。如果上一次生成 ID 时用的是 11:40:12.701回拨后又生成一次时间戳还是 11:40:12.701重复风险极高。位数和精度不稳定秒级时间戳 10 位毫秒级 13 位微秒级可能 16 位。如果库表字段长度设计得太死很容易溢出如果后端把 19 位雪花 ID 直接下发给前端 JS精度还会丢。所以裸时间戳只适合“记录日志时间”这种没有唯一性要求的场景。要当 ID 用必须加后缀。3. 实操过程从 13 位时间戳到完整 ID 生成器3.1 确定 ID 的位段划分我采用的方案是类雪花算法。简单说就是把 64 位整数拆成几段位段长度说明时间戳差值41 位当前毫秒减去自定义起始时间支持大约 69 年机器 ID10 位标识不同节点支持 0-1023序列号12 位同一毫秒内自增支持 0-4095起始时间我选了 2026-01-01 00:00:00 UTC也就是 1767225600000 毫秒。这样做的好处是 ID 里的时间戳部分不用从 1970 开始累加整体位数更短而且能把可用时间延长到 2095 年左右。如果按 1970 当起点41 位时间戳到 2039 年附近就会溢出。3.2 写一个最小可用的生成器Python 实现大概长这样import time import threading class SnowflakeIdGenerator: def __init__(self, worker_id: int, epoch_ms: int 1767225600000): self.worker_id worker_id 0x3FF self.epoch_ms epoch_ms self.sequence 0 self.last_ts -1 self._lock threading.Lock() def _current_ms(self): return int(time.time() * 1000) def _wait_next_ms(self, last_ts): ts self._current_ms() while ts last_ts: ts self._current_ms() return ts def next_id(self): with self._lock: ts self._current_ms() if ts self.last_ts: raise RuntimeError(Clock moved backwards) if ts self.last_ts: self.sequence (self.sequence 1) 0xFFF if self.sequence 0: ts self._wait_next_ms(self.last_ts) else: self.sequence 0 self.last_ts ts return ((ts - self.epoch_ms) 22) | (self.worker_id 12) | self.sequence核心逻辑不复杂如果当前时间和上次生成时间相同序列号加一。如果序列号溢出也就是同一毫秒内已经生成超过 4096 个 ID就自旋等待下一毫秒。如果当前时间小于上次生成时间说明时钟被回拨了直接抛异常宁可报错也不能生成重复 ID。生成出来的 ID 会是一个 64 位整数。在 Python 里可以直接用在 Java 里对应long。注意这个 ID 和开头那串 1769226012701 不是一个东西1769226012701 是生成器内部从System.currentTimeMillis()拿到的时间戳最终 ID 是把这段时间戳做偏移、左移、按位或之后的结果。日志里看到这串 13 位数字只能说明它是某个 ID 生成器的时间来源不代表它一定是完整 ID。3.3 从 ID 反解时间戳和机器信息既然能用时间戳拼 ID那也要能从 ID 把时间戳解出来。写个解析函数def parse_id(id_value: int, epoch_ms: int 1767225600000): ts_diff id_value 22 worker_id (id_value 12) 0x3FF sequence id_value 0xFFF ts_ms epoch_ms ts_diff return ts_ms, worker_id, sequence这个操作在实际排查里非常有用。你拿到一个 19 位或者 16 位 ID不用去查库直接右移 22 位加上起始 epoch就得到它生成时的毫秒时间戳。再屏蔽掉低 12 位就能还原出是哪台机器生成的。这套“编码即查询”的思路在处理分布式日志时能省下不少功夫。3.4 把生成器接到业务链路里代码写出来只是第一步真正重要的是接入方式。我当时做的改动是在 Web 层做一个拦截器每次请求进来时调next_id()生成requestId。把requestId放进日志框架的 MDC这样同一条请求的所有日志都能带上同一个 ID。服务端响应头带上X-Request-Id前端出问题的时候拿着这个 ID 就能反查全链路。如果请求要写数据库就把requestId作为幂等键在 Redis 里用SETNX占位重复请求直接拒绝。这里有个容易忽略的点requestId必须在前端和服务端之间字符串传递。如果后端是 JavaLongJSON 序列化后传给 JS一旦 ID 超过Number.MAX_SAFE_INTEGER精度就会丢。所以对于长 ID接口字段要么定义成string要么在 JSON 序列化时统一转成字符串。4. 常见问题与排查技巧实录4.1 同一个 ID 解析出来却完全对不上时间这是新手最常碰到的。原因基本就两种毫秒当秒用比如把 13 位的 1769226012701 传给只接受秒级时间戳的工具会得到一个奇怪的四位年份日期。时区没处理13 位时间戳本身是 UTC 的转北京时间要加 8 小时。很多人用在线转换工具时默认拿到 UTC 时间然后发现比业务时间少了 8 小时。排查的时候别急着怪“时间戳错了”先确认你手里是秒还是毫秒再看工具用的时区是什么。4.2 并发一高就出现重复 ID这个故障我踩过。压测刚开始日志里就会看到 duplicate key 报错。检查发现ID 生成代码写的是long id System.currentTimeMillis();单线程测试没问题因为同一个毫秒只执行一次。并发一上来两个线程在同一毫秒同时拿到相同的时间戳主键冲突是必然的。解决办法就是前面说的序列号机制。序列号 12 位意味着单机单毫秒最多生成 4096 个 ID换算下来单机每秒能撑住约 400 万次生成正常系统根本用不满。如果你的业务真的超过这个量级可以吧序列号扩展到 14 位或者直接拆成多机分摊。4.3 服务器时钟回拨导致的“灵异事件”线上环境经常用 NTP 做时钟同步偶尔会回拨几十毫秒。表现是生成器突然抛Clock moved backwards或者出现时间倒流的 ID。我的处理方式是分场景回拨小于 10ms可以短暂自旋等待等本机时间追平期间生成请求稍微延迟。回拨大于 10ms不要再继续生成 ID直接返回错误让调用方走降级逻辑。硬扛只会产生重复 ID后面更难排查。更稳妥的做法为每台机器分配不同的 worker_id即使同一毫秒回拨不同机器生成的 ID 也不会撞。4.4 前端拿到 ID 后精度丢失这个问题在 19 位雪花 ID 上特别常见。JavaScript 的Number类型只能精确表示2^53 - 1以内的整数大约是 9007199254740991而标准雪花 ID 通常是 19 位早就超出安全范围。前端解析一个 Long 型 ID最后几位会变成...000一传回来就和原 ID 不一致。解决办法是约定所有 ID 字段在 JSON 里输出成字符串。Java 里用JsonSerialize(using ToStringSerializer.class)或者全局配置Long - String的策略。如果前端必须做运算也要用 BigInt别直接用普通 Number。4.5 日志里想按 ID 排序结果顺序乱掉时间戳型 ID 的趋势有序只体现在“整体趋势”上不是严格有序。因为不同机器的时间可能会有细微偏差A 机器生成的下一个 ID 不一定比 B 机器上一个 ID 大。如果你的日志查询依赖 ID 排序最好还是加一个独立的业务时间字段不要只看 ID。个人习惯线上遇到 13 位数字先翻译成时间再在日志平台按时间范围过滤看到重复主键先检查是不是同一毫秒看到 19 位 ID 丢了精度先检查接口有没有把 Long 转成 String。这三板斧能解决九成以上的时间戳 ID 相关问题。5. 复盘我在这个项目里的几条实际体会最后分享几条这个项目带给我的直接经验。第一能用时间表达的信息就不要额外再存一个字段。1769226012701 这个数字出现在日志里你就已经知道它对应的是 2026-01-24 11:40:12.701不需要再去日志服务里查“创建时间”。这种编码信息虽然刚看不直观但长期看能省不少存储和查询成本。第二方案没有绝对好坏只有适不适合。单机单体小项目数据库自增主键依然是最稳的选择。一旦拆了服务、分了库表才需要这种本地生成、全局唯一的 ID 方案。不要为了炫技把简单系统搞复杂。第三时钟、机器号、精度这三件事一定要在测试环境模拟一遍。我第一次上线前只测了唯一性忽略了机器号重复和前端精度的问题结果差点在联调阶段踩进去。现在我会在测试计划里专门加一条把 NTP 回拨、并发压测、ID 转 JSON 这三个场景全跑一遍确认没有告警之后再滚动发布。
返回列表