用 Redis 过期键与发布订阅实现简单的延迟任务队列
前阵子项目里有个需求:订单下单后 15 分钟没支付,就自动把订单关掉、库存还回去。这种典型的延迟任务场景,教科书答案是上 RabbitMQ 的死信队列,或者整个 xxl-job 之类的调度平台。但项目本来就只挂了一个 Redis,为了这么点功能再引入一个消息中间件,运维那边多半不乐意,我也嫌麻烦。后来翻 Redis 文档,发现 key 过期的时候是能主动发一条事件出来的,琢磨了一下感觉够用了,就拿它凑合搭了个简易延迟队列,跑了几个月没出什么大问题,记录一下。
整体思路
原理其实很简单,三步:
- 下单时往 Redis 写一个 key,比如
delay:task:123456,然后给它设置 15 分钟的过期时间 - 这个 key 过期时,Redis 会往
__keyevent@0__:expired这个频道发布一条过期事件,事件内容就是 key 名本身 - 业务进程里起一个订阅者常驻监听这个频道,收到事件后解析出任务标识,去执行关单逻辑
有个小坑要先说:key 一过期 value 就没了,事件里只带 key 名。所以任务详情不能塞 value 里等过期再取,取不到的。我的做法是把任务详情单独存一个 hash,key 里只放任务 ID,过期事件来了之后按 ID 去捞详情。当然你也可以把订单号直接编码进 key 名里,看个人口味。
先把过期事件通知打开
这个功能默认是关着的(大概是怕事件太多把订阅端打爆吧),得手动开。临时开的话,连上 redis-cli 直接执行:
CONFIG SET notify-keyspace-events Ex
这里的 Ex,大写 E 表示以 keyevent 模式发布事件,小写 x 表示只监听过期事件。注意大小写是敏感的,写成小写 e 就是另一个含义了(那是内存淘汰事件),我就栽过一回,配置改了半天愣是不生效。想重启后也生效的话,去 redis.conf 里加一行:
notify-keyspace-events Ex
另外如果你用的是云厂商的 Redis,一般是去控制台的参数设置里改,不同厂商入口藏的地方都不一样,慢慢找吧。
上代码
发布端就是普通的 set + expire,顺手把详情存进 hash:
import json
import redis
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
def submit_task(task_id, payload, delay):
# 任务详情单独存,过期事件里只有 key 名,value 是拿不回来的
r.hset("delay:detail", task_id, json.dumps(payload))
r.setex(f"delay:task:{task_id}", delay, task_id)
订阅端:
import json
import redis
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
pubsub = r.pubsub()
pubsub.subscribe("__keyevent@0__:expired")
for msg in pubsub.listen():
if msg["type"] != "message":
continue
key = msg["data"].decode()
if not key.startswith("delay:task:"):
continue
task_id = key.rsplit(":", 1)[-1]
detail = r.hget("delay:detail", task_id)
if detail is None:
continue
print("到点了,干活:", json.loads(detail))
# 这里执行真正的业务,比如关单、还库存
r.hdel("delay:detail", task_id)
大概就这么点东西,跑起来是真能用。调试的时候建议先在另一个终端挂一个 redis-cli SUBSCRIBE __keyevent@0__:expired,看看事件到底有没有发出来,别上来就怀疑自己的代码,我一开始就是配置没开对,白查了半天。
必须知道的几个坑
丑话说在前面,这个方案有几个天然缺陷,用之前想清楚:
过期时间不精确。 Redis 删 key 是惰性删除加定期抽样删除,过期事件是在 key 真正被删掉的那一刻才发布的。也就是说一个 key 过期了,如果之后一直没人碰它,定期任务又没扫到它,事件就会一直憋着不发。实际用下来晚个一两秒是常态,我甚至遇到过晚十几秒的。对时间精度敏感的场景,别用这个。
pub/sub 没有持久化。 Redis 的发布订阅是纯内存、发完即走的,没有 ACK,也没有离线消息。订阅进程断线或重启那段时间里产生的过期事件,直接就丢了,Redis 不会补发。我的土办法是订阅端每次重连成功后,全量扫一遍还没处理的任务对一遍账,再把业务逻辑做成幂等,就算真丢了一两条,下次对账也能补回来。
多实例的语义要想好。 pub/sub 是广播,每个订阅者都能收到同一条事件。如果你部署了多个消费实例,同一个任务就会被消费多次,要么加分布式锁,要么像项目里这样,指定一个专门的处理进程去干这活,其他实例只负责提交任务。
最后
说白了这就是薅 Redis 羊毛,拿它的过期通知硬凑了个延迟队列。好处是零成本,项目里本来就有 Redis,一行中间件都不用加,代码量也就几十行;缺点上面也都列了,不精确、会丢消息、兜底得自己做。
个人感觉它适合“晚几秒无所谓,偶尔丢一两条能容忍或者有补偿手段”的场景,比如超时关单、延迟推送提醒这类边缘需求。真要是核心链路、任务量大、时间敏感,还是老老实实上专业的消息队列吧,别为难 Redis 了。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。