应使用ZADD+ZRANGEBYSCORE实现延迟队列,score设为毫秒级Unix时间戳,消费时用WITHSCORES拉取并立即ZREM,避免轮询开销与精度丢失。

用 SortedSet 存时间戳,别用 LPUSH + BRPOPLPUSH
Redis 原生没有延迟队列,硬凑 LPUSH 加 BRPOPLPUSH 轮询会吃 CPU、丢任务、难监控。真正靠谱的做法是用 ZADD 把任务塞进 SortedSet,score 设为到期毫秒时间戳(比如 DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),消费端用 ZRANGEBYSCORE 拉取已到期的项。
- 必须用毫秒级 Unix 时间戳做 score,不是秒级——
DateTimeOffset.UtcNow.ToUnixTimeSeconds()会导致精度丢失,多个任务挤在同一秒时顺序不可控 - 拉取时要带
WITHSCORES并立即ZREM,否则重复消费:先ZRANGEBYSCORE queue -inf now LIMIT 0 10,再对返回的每条ZREM queue key - 别在单次请求里拉太多(比如 LIMIT 1000),网络抖动或处理卡顿会导致一批任务集体延迟;建议 LIMIT 10~50,配合短间隔轮询(如 100ms)
StackExchange.Redis 的 Database.SortedSetRangeByScoreWithScores 怎么用才不翻车
这个 API 看似直接,但几个参数不设对就会漏数据或阻塞主线程:
-
exclude参数必须设为Exclude.None,否则ZRANGEBYSCORE queue -inf 1717023456789会把 score 等于 1717023456789 的任务排除掉——而你刚塞进去的任务就卡在这个值上 -
order要用Order.Ascending,不然拉出来的是“最远将来的任务”,不是“最早该执行的” - 别用同步方法(如
SortedSetRangeByScore),必须用异步版SortedSetRangeByScoreWithScoresAsync,否则高并发下线程池耗尽,整个 Web API 响应变慢甚至超时 - 示例片段:
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var tasks = await db.SortedSetRangeByScoreWithScoresAsync("delay-queue", min: "-inf", max: now.ToString(), exclude: Exclude.None, order: Order.Ascending, skip: 0, take: 20);
超时任务怎么“主动取消”而不是等它自然失败
延迟队列里的任务一旦提交,就不能靠 Redis 自己“中断”。所谓“超时取消”,本质是消费端在执行前多一次校验:
- 从
SortedSet拉出任务后,立刻反序列化并读取原始入队时间(比如 JSON 里存了EnqueueTimeUtc字段) - 计算当前时间与
EnqueueTimeUtc的差值,如果超过业务定义的最大容忍时长(如 30 秒),直接丢弃,不进业务逻辑 - 不要依赖 Redis 的 TTL 或
EXPIRE——SortedSet成员不支持单独过期,强行给整个 key 设 TTL 会把还没到点的任务一起清掉 - 更稳妥的做法是在任务体里自带
DeadlineUtc字段,消费时比对DateTimeOffset.UtcNow ,过期就跳过
为什么不用 Hangfire 或 Quartz.NET?
它们不是不能用,而是引入复杂度和单点风险:
-
Hangfire依赖 SQL Server 或 Redis 持久化 job 状态,但它的“延迟执行”底层仍是轮询JobQueue表,和手写SortedSet方案性能模型一致,却多一层序列化/反序列化开销 -
Quartz.NET默认用 RAMJobStore,重启即丢任务;切到 ADO.NET Store 又得配连接池、处理死锁、升级 schema - 如果你只需要“5 分钟后发短信”“2 小时后清理缓存”这类轻量级调度,手写 200 行
StackExchange.Redis+ 后台服务轮询,更透明、更易 debug、上线零配置
真正容易被忽略的点是:score 必须用毫秒时间戳且全局统一时区,哪怕服务器时间偶尔漂移,也要靠 NTP 对齐——否则不同节点写入的 score 和消费端读取的 now 不在一个标尺上,任务就永远“不到期”。











