真正可用的方案是带时间戳前缀+原子自增:高位为毫秒级时间戳保证趋势递增,低位为Redis INCRBY原子生成的该毫秒内序号,通过分片key(如seq:answer:2026071415)避免热点,并配以跳号或预生成池兜底,确保全局唯一、单调递增且高可用。

流水号必须单调递增且全局唯一,用 INCR 最直接;但语言学习类业务(如答题、打卡、积分)常需“带时间前缀+自增”或“按用户/课程分片”,硬套 INCR 会撞上限、难追溯、无法降级——得根据场景选模式。
INCR 能直接用,但得避开三个坑
很多人一上来就写 client.Incr(ctx, "seq:order"),结果上线后出现跳号、重复、Redis挂了服务直接瘫痪。问题不在命令本身,而在调用方式和兜底逻辑。
-
INCR返回的是int64,不是字符串;若业务需要固定长度(如"00000123"),必须在 Go 层格式化,不能靠 Redis 的INCR自动补零 - 没设初始值时,
INCR对不存在的 key 会从 1 开始;但若某次写入失败、重试又成功,就会漏掉一次——得用client.SetNX(ctx, "seq:lesson", "0", 0)首次初始化 - ctx 必须带超时:
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond);否则 Redis 响应慢会导致 goroutine 积压,拖垮整个服务
语言学习场景更适合分片 + 时间前缀
答题系统每秒可能有上万用户提交,全打到一个 seq:answer key 上,Redis 单线程会成为瓶颈;而且“第 1000000 题是谁答的”这种查询,光靠数字 ID 很难关联到课程、日期、用户。更合理的做法是把维度信息前置。
- 按小时分片:
seq:answer:2026071415(表示 2026-07-14 15 点),用INCR独立计数,避免热点 key - 拼接生成最终 ID:
fmt.Sprintf("ANS-%s-%06d", time.Now().UTC().Format("2006010215"), seq),前端和日志都可读 - 注意时区:统一用
time.Now().UTC(),别用Local(),否则跨服务器时间不一致会导致同一小时生成多个分片 key - 分片 key 不要无限增长:启动时用
MGET批量拉最近 24 小时的 key 初始值,旧分片不主动清理,靠 Redis 的EXPIRE自动过期(比如设 48 小时 TTL)
Redis 故障时不能切本地计数器
见过太多项目在 err != nil 时 fallback 到 atomic.AddInt64(&localSeq, 1),结果集群里每个实例都从 0 开始狂增,ID 瞬间重复爆炸。这不是降级,是埋雷。
立即学习“go语言免费学习笔记(深入)”;
- 真正可用的兜底只有两种:跳号(如预留 1000 个号段,故障时直接 +1000)、或只读池(启动时预生成 1w 个 ID 存
sync.Map,用完即止) - 跳号方案要配开关:
var failoverEnabled atomic.Bool,由运维手动打开,避免自动触发雪崩 - 预生成池必须带校验:每次取号后用
redis.Exists(ctx, "seq:used:"+id).Val() == 0确认未被其他节点抢先写入,再SETNX占位 - 所有兜底逻辑必须记录 warn 日志,包含
redis.ErrNil和redis.Timeout的区分,方便快速定位是网络抖动还是集群宕机
分片逻辑看着多几行代码,但比扛着单 key 硬刚高并发靠谱得多;而兜底不是“有没有”,是“怎么控”。ID 一旦发出去,就再也收不回来——所有设计都得围绕“不可逆”来约束。


















