转:基于Redis实现延时队列
摘要:使用 sortedset,拿时间戳作为score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。
前段时间做一个项目,需要各种定时任务处理会话状态,处理订单状态,然后需求不停的变,修修改改就觉得很麻烦,就去了解了一下有没有什么便捷的方式取代繁琐的定时任务,于是就找到了延迟队列的这种实现方式。
一、应用场景
- 订单超过 30 分钟未支付,则自动取消。
- 订单一些评论,如果48h用户未对商家评论,系统会自动产生一条默认评论。
- 外卖商家超时未接单,则自动取消订单。
- 医生抢单电话点诊,超过 30 分钟未打电话,则自动退款。
如上场景都可以用定时任务去轮询实现,但是当数据量过大的时候,高频轮询数据库会消耗大量的资源,此时用延迟队列来应对这类场景比较好。
二、需求
根据自身业务和公司情况,如果实现一个自己的延时队列服务需要考虑一下几点:
- 消息存储
- 过期延时消息实时获取
- 高可用性
三、为什么使用 Redis 实现?
延时队列就是一种带有延迟功能的消息队列。下面会介绍几种目前已有的延时队列。
3.1、RabbitMQ 延时队列
优点:消息持久化,分布式。
缺点:延时相同的消息必须扔在同一个队列,每一种延时就需要建立一个队列。因为当后面的消息比前面的消息先过期,还是只能等待前面的消息过期,这里的过期检测是惰性的。
使用: RabbitMQ 可以针对 Queue 设置 x-expires 或者针对 Message 设置 x-message-ttl ,来控制消息的生存时间(可以根据 Queue 来设置,也可以根据 message 设置), Queue 还可以配置 x-dead-letter-exchange 和 x-dead-letter-routing-key(可选)两个参数,如果队列内出现了 dead letter ,则按照这两个参数重新路由转发到指定的队列,此时就可以实现延时队列了。
RabbitMQ天然具备分布式的特性,可以很好的用在多服务。
3.2、DelayQueue 延时队列
优点:无界、延迟、阻塞队列
缺点:非持久化
介绍:JDK 自带的延时队列,没有过期元素的话,使用 poll() 方法会返回 null 值,超时判定是通过getDelay(TimeUnit.NANOSECONDS) 方法的返回值小于等于0来判断,并且不能存放空元素。
使用:getDelay 方法定义了剩余到期时间,compareTo 方法定义了元素排序规则。poll() 是非阻塞的获取数据,take() 是阻塞形式获取数据。实现 Delayed 接口即可使用延时队列。
注意事项:DelayQueue 实现了 Iterator 接口,但 iterator() 遍历顺序不保证是元素的实际存放顺序。
/**
* 实现 Delayed 定义延时队列
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Sequence implements Delayed {
private Long time;
private String name;
@Override
public long getDelay(TimeUnit unit) {
return time - System.currentTimeMillis();
}
@Override
public int compareTo(Delayed o) {
if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) {
return 1;
} else if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) {
return -1;
} else {
return 0;
}
}
}
3.3、Scala 的 Await & Future
优点:消息实时性
缺点:非持久化
介绍:Scala 的 ExecutionContext 中使用Await 的 result(awaitable: Awaitable[T], atMost: Duration)方法可以根据传入的 atMost 间隔时间异步执行 awaitable。
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.{Await, Future}
object test extends App {
val task = Future{ doSomething() }
Await.result(task, 5 seconds)
}
3.4、Redis 延迟队列
- 消息持久化,至少被消费一次。
- 实时性:存在一定的时间误差(定时任务间隔)。
- 支持指定消息 remove。
- 高可用性。
- Redis 的特殊数据结构 ZSet 满足延迟的特性。
四、Redis 的使用
4.1、使用 sortedset 操作元素
赋值:zadd key score1 value1 score2 value2... (把全部的元素添加到sorted set中,并且每个元素有其对应的分数,返回值是新增的元素个数。)
获取元素:
- zscore key value:返回指定成员的分数
- zcard key : 获取集合中的成员数量
删除元素:zrem key value1 value2 … 删除指定元素
- zremrangebyrank key start stop:按照排名范围删除元素。
- zremrangebyscore key min max:按照分数范围删除元素。
查询元素:
- zrange key start end withscores:查询start到end之间的成员。
- zrevrange key start end withscores:查询成员分数从大到小顺序的索引 start 到 end 的所有成员。
- zrangebyscore key min max withscores limit offset count:返回分数 min 到 max 的成员并按照分数从小到大排序, limit 是从 offset 开始展示几个元素。
4.2、Redis 实现方式
使用sortedset,用时间戳作为score,使用zadd key score1 value1
命令生产消息,使用zrangebysocre key min max withscores limit 0 1消费消息最早的一条消息。
这里选用 Redis 主要的原因就是其支持高性能的 score 排序,同时 Redis 的持久化 bgsave 特性,保证了消息的消费和存贮问题。bgsave 的原理是 fork 和 cow。fork 是指 Redis 通过创建子进程来进行 bgsave 操作, cow 指的是copy on write, 子进程创建后, 父进程通过共享数据段, 父进程继续提供读写服务, 写脏的页面数据会逐渐和子进程分离开来。
4.3、ACK
队列最重要的就是保证消息被成功消费,这里也不可避免的需要考虑这个问题。
RabbitMQ 的 ACK机制:Publisher 把消息发送到 Consumer,如果 Consumer 已处理完任务,那么它将向 Broker 发送 ACK 消息,告知某条消息已被成功处理,可以从队列中移除。如果 Consumer 没有发送回 ACK 消息,那么 Broker 会认为消息处理失败,会将此消息及后续消息分发给其它 Consumer 进行处理 ( redeliver flag 置为 true )。
这种确认机制和 TCP/IP 协议确立连接类似。不同的是,TCP/IP 确立连接需要经过三次握手,而 RabbitMQ 只需要一次 ACK。还有一个重要的是,RabbitMQ 当且仅当检测到 ACK 消息未发出且 Consumer 的连接终止时才会将消息重新分发给其他 Consumer ,因此不需要担心消息处理时间过长而被重新分发的情况。
Redis 实现 ACK
- 需要在业务代码中处理消息失败的情况,回滚消息到原始等待队列。
- Consumer 挂掉,仍然需要回滚消息到等待队列中。
- 前者只需要在业务中处理消费异常的情况,后者则需要维护两个队列。
Redis ACK 实现方案
维护一个消息记录表,存贮消息的消费记录,用于失败时回滚消息。表中记录消息ID、消息内容、消息时间、消息状态。
定时任务轮询该消息表,处理消费记录表中消费状态未成功的记录,重新放入等待队列。
4.4、多实例问题
多实例是指同一个服务部署在不同的地方,发挥相同的作用,此时就会导致同时消费同一个消息的问题。
一般情况下解决此类问题就需要考虑接入外部应用的辅助。常见的分布式锁的方案有:基于数据库实现分布式锁、基于缓存实现分布式锁、基于 Zookeeper 实现分布式锁。这里使用基于Redis的缓存来解决问题。
利用 Redis 的 setnx 的互斥特性,把 key 当作锁存在 Redis 中,但是用 setnx 需要解决死锁和正确解锁的问题。
死锁:设置 key-value 的过期时间,并且使用 lua 脚本保证加锁和设置过期时间的原子性。
解锁:解锁需要保证是加锁客户端进行解锁操作。将 value 设置为 UUID,用对应的 UUID 去解锁保证是加锁客户端进行对应的解锁操作。
利用 Redis 的 List 实现一个 Publisher 推送消费保证只被消费一次,这种不用考虑死锁问题,但是需要额外维护一个队列。
五、Jedis实现简单延时队列
Zset本质就是Set结构上加了个排序的功能,除了添加数据value之外,还提供另一属性score,这一属性在添加修改元素的时候可以指定,每次指定后,Zset会自动重新排序。可以理解为有两列字段的数据表,一列存value,一列存次序编号。操作中key理解为zset的名字。那么这个特性对延时队列又有何用呢?试想如果score代表的是任务想要执行的时间戳,zset便会按照score的时间戳大小进行排序,也就是对执行时间进行排序。这样的话,起一个死循环线程不断地进行取第一个key值,如果当前时间戳不小于该socre,就将它取出来消费并删除,从而达到延时执行的目的。注意不需要遍历整个zset集合,以免造成性能浪费。
package cn.chinotan.service.delayQueueRedis;
import org.apache.commons.lang3.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Tuple;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* @program: test
* @description: 单实例redis实现延时队列
**/
public class AppTest {
private static final String ADDR = "127.0.0.1";
private static final int PORT = 6379;
private static JedisPool jedisPool = new JedisPool(ADDR, PORT);
private static CountDownLatch cdl = new CountDownLatch(10);
public static Jedis getJedis() {
return jedisPool.getResource();
}
/**
* 生产者,生成5个订单
*/
public void productionDelayMessage() {
for (int i = 0; i < 5; i++) {
Calendar instance = Calendar.getInstance();
// 3秒后执行
instance.add(Calendar.SECOND, 3 + i);
AppTest.getJedis().zadd("orderId", (instance.getTimeInMillis()) / 1000, StringUtils.join("000000000", i + 1));
System.out.println("生产订单: " + StringUtils.join("000000000", i + 1) + " 当前时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
System.out.println((3 + i) + "秒后执行");
}
}
//消费者,取订单
public static void consumerDelayMessage() {
Jedis jedis = AppTest.getJedis();
while (true) {
Set<Tuple> order = jedis.zrangeWithScores("orderId", 0, 0);
if (order == null || order.isEmpty()) {
System.out.println("当前没有等待的任务");
try {
TimeUnit.MICROSECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
Tuple tuple = (Tuple) order.toArray()[0];
double score = tuple.getScore();
Calendar instance = Calendar.getInstance();
long nowTime = instance.getTimeInMillis() / 1000;
if (nowTime >= score) {
String element = tuple.getElement();
Long orderId = jedis.zrem("orderId", element);
if (orderId > 0) {
System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + ":redis消费了一个任务:消费的订单OrderId为" + element);
}
}
}
}
static class DelayMessage implements Runnable{
@Override
public void run() {
try {
cdl.await();
consumerDelayMessage();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
AppTest appTest = new AppTest();
appTest.productionDelayMessage();
for (int i = 0; i < 10; i++) {
new Thread(new DelayMessage()).start();
cdl.countDown();
}
}
}
实现效果如下:

六、小结
使用 Redis 实现的队列具有很好的扩展性,可以很便捷的应对需求的变更和业务的扩展,但是对于简单的场景直接使用定时任务会更加容易。在有大量的定时任务需要实现的时候,就可以考虑使用延迟队列去实现,让代码更具有扩展性。
Redis 作为消息队列的局限性很大,实现 ack 机制的成本相对较高,然而他的轻量级的特性以及兼容很多的数据结构,Redis 成熟的分布式、持久化、集群等技术体系,让他可以实现一些轻量级的队列。总之没有最好的技术,只有最好的 developer。
Reference
- https://zhuanlan.zhihu.com/p/87113913
- https://www.cnblogs.com/lylife/p/7881950.html
- https://my.oschina.net/u/3266761/blog/1930360
转:基于Redis实现延时队列的更多相关文章
- 基于Redis实现延时队列服务
背景 在业务发展过程中,会出现一些需要延时处理的场景,比如: a.订单下单之后超过30分钟用户未支付,需要取消订单 b.订单一些评论,如果48h用户未对商家评论,系统会自动产生一条默认评论 c.点我达 ...
- 【转】基于Redis实现延时队列服务
背景 在业务发展过程中,会出现一些需要延时处理的场景,比如: a.订单下单之后超过30分钟用户未支付,需要取消订单b.订单一些评论,如果48h用户未对商家评论,系统会自动产生一条默认评论c.点我达订单 ...
- Redis简单延时队列
Redis实现简单延队列, 利用zset有序的数据结构, score设置为延时的时间戳. 实现思路: 1.使用命令 [zrangebyscore keyName socreMin socreMax] ...
- 基于Redis的消息队列php-resque
转载:http://netstu.5iunix.net/archives/201305-835/ 最近的做一个短信群发的项目,需要用到消息队列.因此开始了我对消息队列选型的漫长路. 为什么选型会纠结呢 ...
- 基于rabbitMQ 消息延时队列方案 模拟电商超时未支付订单处理场景
前言 传统处理超时订单 采取定时任务轮训数据库订单,并且批量处理.其弊端也是显而易见的:对服务器.数据库性会有很大的要求,并且当处理大量订单起来会很力不从心,而且实时性也不是特别好 当然传统的手法还可 ...
- 基于Redis实现延迟队列
背景 在后端服务中,经常有这样一种场景,写数据库操作在异步队列中执行,且这个异步队列是多进程运行的,这时如果对同一资源进行写库操作,很有可能产生数据被覆盖等问题,于是就需要业务层在更新数据库之前进行加 ...
- [视频教程] 基于redis的消息队列实现与思考
使用redis的list列表来实现消息队列功能,相信大家都听过消息队列,但是在业务中可能并没有真正去使用它.在公司项目中正好有个场景使用到了消息队列,因此就来说一下流程.在web界面上有个功能是群发邮 ...
- 基于Redis的消息队列使用:spring boot2.0整合redis
一 . 引入依赖 <?xml version="1.0" encoding="UTF-8"?> <project xmlns="ht ...
- Redis学习笔记之延时队列
目录 一.业务场景 二.Redis延时队列 一.业务场景 所谓延时队列就是延时的消息队列,下面说一下一些业务场景比较好理解 1.1 实践场景 订单支付失败,每隔一段时间提醒用户 用户并发量的情况,可以 ...
- redis实现简单延时队列(转)
继之前用rabbitMQ实现延时队列,Redis由于其自身的Zset数据结构,也同样可以实现延时的操作 Zset本质就是Set结构上加了个排序的功能,除了添加数据value之外,还提供另一属性scor ...
随机推荐
- AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
一.前言:AI编程时代的双雄争霸 2025年3月,字节跳动推出的Trae以"国内首个AI原生IDE"之名杀入战场,直指海外明星产品Cursor的软肋.这场工具革命背后,是免费与付费 ...
- 变量命名不规范&我被deepseek骗了
首先是一个实体类 @Data public class Dto {private String mNumber; } 前端传来{"mNumber:"123"}为null的 ...
- Landsat遥感影像分幅条带介绍与矢量下载:WRS的Path与Row
本文介绍Landsat系列卫星的分幅规则,并提供WRS的矢量文件下载. WRS,即Worldwide Reference System,是Landsat系列卫星全球影像标记符号系统,用以区分全 ...
- sql server 与 mysql 中常用的SQL语句区别
sql server 与 mysql 的区别 由于博主之前学过mysql,目前在学习SQL server,原来以为这两个数据库之间的sql语句应该不会有太大区别.但是学sql server(用的版 ...
- jquery简单的上传图片预览
html <div id="bcd"></div> <input type="file" id="abc"&g ...
- selenium自动化测试入门
Selenium是一个基于浏览器的自动化测试工具,它提供了一种跨平台.跨浏览器的端到端的web自动化解决方案. Selenium是用于自动化控制浏览器做各种操作,打开网页,点击按钮,输入表单等等,可以 ...
- 队列的实现方式(先进先出 FIFO)--环形队列
博客地址:https://www.cnblogs.com/zylyehuo/ # -*- coding: utf-8 -*- class Queue: def __init__(self, size= ...
- 【CF比赛记录】Codeforces Round 1013 (Div. 3)
比赛链接 本文发布于博客园,会跟随补题进度实时更新,若您在其他平台阅读到此文,请前往博客园获取更好的阅读体验. 跳转链接:https://www.cnblogs.com/TianTianChaoFan ...
- 最火的 Python 异步 Web 框架的综合对比分析
以下是当前最火的 Python 异步 Web 框架的综合对比分析,涵盖性能.技术特性和适用场景,并补充其他值得关注的框架: 一.主流异步框架横向对比 1. FastAPI • 核心优势:基于 Star ...
- 使用Python+SymPy计算无穷级数
引言 在数学中,级数是指由数列的无限项组成的求和表达式.无穷级数的求和是一个非常重要且具有挑战性的数学问题,特别是在信号处理.物理学和工程学等领域.今天,我们将介绍如何利用 Python 中的 Sym ...