首页 / 资讯中心 / 文章详情

Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现

Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现 ★ FEATURED ARTICLE
我不会起名字322· 后端 / 算法 / 数据库 技术栈 力扣 Hot100 Go 项目 Redis MySQL文章目录Redis 从 0 到 1十七订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现一、现象每分钟一次的全表扫描二、为什么是 ZSet 而不是 List三、Go 实操完整延迟队列收藏点 1四、关键设计决策收藏点 2五、命令清单六、排查步骤延迟任务没执行七、总结Redis 从 0 到 1十七订单 30 分钟不支付自动关闭——ZSet 延迟队列 Go 实现上一篇《Redis 从 0 到 1十六1 亿 UV 只占 12KB计数去重三件套的 Go 实测》我们搞定了计数与去重链接https://blog.csdn.net/2501_92769340/article/details/167399787 。本篇处理到点执行类需求订单超时未支付要自动关闭、红包到期要退回、秒杀结束后要结算。一、现象每分钟一次的全表扫描第一版超时关单方案定时任务每分钟跑一次SELECT * FROM orders WHERE statusunpaid AND created_at NOW() - INTERVAL 30 MINUTE。三个问题大表上这个查询越来越慢索引压力大扫描周期 关单精度分钟级想秒级就得每秒扫无效扫描90% 的时间扫出来是 0 条大部分订单要么付了要么在 30 分钟内。正确姿势事件驱动——订单创建时就往延迟队列里丢一条30 分钟后关闭我的任务到点消费没有轮询、没有扫描、精度秒级。二、为什么是 ZSet 而不是 List方案到期感知支持取消复杂度List 定时全量检查靠轮询要遍历删O(N)ZSetscore执行时间戳ZRANGEBYSCORE ≤ now一次捞出全部到期任务ZREM精确取消O(logNM)Redis key 空间事件EXPIRE有通知但拿不到内容——只适合缓存失效ZSet 的方案在 Redis 4 之前没法原子取走取和删是两步可能重复消费但配合第 13 篇的 WATCH/MULTI 就能做到检查删除原子化——这正是本系列不用 Lua 路线的一次实战。三、Go 实操完整延迟队列收藏点 1队列五件套生产、消费原子取出、ACK、重试、死信。packagedelayqimport(contexterrorsfmttimegithub.com/redis/go-redis/v9)varErrNoTaskerrors.New(没有到期任务)typeTaskstruct{IDstring// 任务唯一 id业务主键RunAt time.Time// 执行时间}// Produce 生产订单创建时调用score 到期时间戳funcProduce(ctx context.Context,rdb*redis.Client,queuestring,idstring,runAt time.Time)error{returnrdb.ZAdd(ctx,dq:queue,redis.Z{Score:float64(runAt.UnixMilli()),Member:id,}).Err()}// Cancel 取消订单支付了把待执行任务删掉funcCancel(ctx context.Context,rdb*redis.Client,queuestring,idstring)error{returnrdb.ZRem(ctx,dq:queue,id).Err()}// Consume 消费原子取出所有到期任务WATCH 保证读到删到之间队列没被并发改动funcConsume(ctx context.Context,rdb*redis.Client,queuestring,batchint64)([]string,error){key:dq:queue now:float64(time.Now().UnixMilli())varids[]string_,err:rdb.TxPipelined(ctx,func(pipe redis.Pipeliner)error{// 1. 读出到期任务[0, now] 区间的 scorerng:pipe.ZRangeByScore(ctx,key,redis.ZRangeBy{Min:0,Max:fmt.Sprintf(%v,now),Offset:0,Count:batch,})if_,err:pipe.Exec(ctx);err!nil{returnerr}idsrng.Val()iflen(ids)0{returnnil}// 2. 事务内删除这批EXEC 前若有并发改动则整体失败外层重试members:make([]any,len(ids))fori,id:rangeids{members[i]id}pipe.ZRem(ctx,key,members...)returnnil})iferr!nil{// 并发冲突返回空让调用方下一循环再来延迟队列不在乎这一次returnnil,nil}returnids,nil}// Ack/Fail 重试与死信消费结果登记在 HashTTL 兜底// 重试执行业务失败后 ZADD 回去score now 退避时间重试超过 3 次进死信// 死信LPUSH dq:queue:dead 告警人工介入消费者worker模板funcWorker(ctx context.Context,rdb*redis.Client,queuestring){ticker:time.NewTicker(500*time.Millisecond)// 轮询粒度 精度deferticker.Stop()for{select{case-ctx.Done():returncase-ticker.C:ids,err:delayq.Consume(ctx,rdb,queue,100)iferr!nil||len(ids)0{continue}for_,id:rangeids{iferr:closeOrder(ctx,id);err!nil{delayq.Retry(ctx,rdb,queue,id)// 退避重试超限进死信continue}delayq.Ack(ctx,rdb,queue,id)}}}}四、关键设计决策收藏点 2决策点本文选择理由任务存什么只存 id内容回业务表查ZSet member 有长度限制且重复存内容浪费内存消费幂等关单前先UPDATE ... WHERE statusunpaid影响行数判断重试/重复消费必然发生业务层幂等并发消费多 worker 抢任务WATCH 冲突时自然错开无需分布式锁精度轮询 500ms30 分钟关单场景秒级足够要毫秒级就别用 Redis五、命令清单命令作用ZADD dq:order ts orderID生产任务ZRANGEBYSCORE dq:order 0 now LIMIT 0 100捞到期任务ZREM dq:order orderID消费后删除 / 业务取消ZCARD dq:order队列积压量监控EXPIRE dq:meta:xxx 86400任务状态 Hash 的 TTL 兜底监控指标队列积压ZCARD 持续上涨 消费者挂了、死信数量、任务平均延迟实际执行时间 - 计划时间。六、排查步骤延迟任务没执行任务在不在队列ZSCORE dq:order id没有 生产没成功或已被消费/取消score 对不对确认存的是毫秒时间戳存成秒的话 30 分钟任务要等 50 小时才到期积压监控ZCARD 是否上涨、worker 是否存活goroutine 泄漏、context 被 cancel死信队列里找LRANGE dq:order:dead 0 -1超过重试上限的任务都在这。七、总结ZSetscore时间戳是 Redis 里做延迟队列的最优结构到期感知、精确取消都是 O(logN)不用 Lua 也能原子消费WATCH 读 ZREM冲突即跳过下一轮任务只存 id幂等靠业务状态机UPDATE … WHERE status…重试 死信 队列积压监控三件套配齐才敢上线。下一篇预告延迟队列是定时发下一篇讲实时发——下一篇《List、Pub/Sub、Stream 为什么生产只用 StreamGo 实操》我们对比三种消息方案并用 Stream 消费组实现至少一次投递。
阅读完成 · 觉得有帮助?
咨询建站