文章目录
- 一、延时队列
- 1、异步消息队列
- 2、延时队列的实现
- Redis 延时队列的优势
- Redis 延时队列的劣势
- 3、Redssion 实现延时队列
- 二、位图
- 1、基本使用
- 2、优雅地使用 Redis 位图操作
- Redis 管道操作
一、延时队列
我们平时习惯使用 RabbitMQ 和 Kafka 作为消息队列中间件来给应用程序之间增加异步消息传递功能,这两个中间件都是专业的消息队列中间件,特性之多超出了大多数人的理解能力。(🤣)
比如,使用 RabbitMQ,发送消息前要创建 Exchange,再创建 Queue,还要将 Queue 和 Exchange 通过某种规则绑定起来,发消息的时候还要指定 routing-key,还要控制头部信息。消费者在消费信息之前也要进行以上操作。但是有时候,我们的消息队列只有一组消费者,还是需要经历以上过程。
有了 Redis ,对于这种只有一组消费者的消息队列,使用 Redis 就可以轻松高度。Redis 的消息队列不是专业的消息队列,没有非常多的高级特性,没有 ack 机制,如果对消息的可靠性有着极致的追求,它就不适合使用。
🤞 以下场景可以考虑使用 Redis 实现的消息队列:
- 快产快消的即时消费场景十分看重速度
-允许出现消息丢失的场景 - 不需要系统保存发送过的消息,做到 来无影去无踪
- 需要处理的数据量并不是那么巨大
1、异步消息队列
Redis 的 list 列表数据结构常用来作为异步消息队列使用,使用 rpush / lpush 操作入队列,使用 lpop / rpop 操作出队列。
以上是简单使用 rpush 和 lpop 的例子,这只是 “队列” 。
客户端通过队列的 pop 操作获取消息,然后进行处理,处理完后接着获取消息,再进行处理。如此循环往复,这就是作为队列消费者的客户端的生命周期。
但是如果队列空了,客户端就会进入 pop 的死循环,不停地 pop,没有数据,再 pop,还是没有数据,这就是浪费生命的空轮询。空轮询不但拉高了客户端的 CPU,Redis 的 QPS【每秒查询率,是对一个特定的查询,服务器在规定时间内所处理流量多少的衡量标准。作为域名系统服务器的机器的性能 经常用每秒查询率来衡量。对应fetches/sec,即每秒的响应请求数,也即是最大吞吐能力。】也会被拉高,如果这样空轮询的客户端有几十来个,Redis 的慢查询可能会显著增多。
通常我们使用 sleep 来解决这个问题,让线程睡一会儿,比如 1s,这样 客户端的 CPU 可以降下来,Redis 的 QPS 也会降下来。不过睡眠会导致消息的延迟增大,可以使用 blpop / brpop 来解决,这两个指令的前缀字符 b 表示 blocking ,也就是 阻塞读 。而如果线程一直阻塞,Redis 的客户端连接就成了闲置连接,如果闲置太久,服务器一般会自动断开连接,来减少闲置资源占用,这时 blpop / brpop 会抛出异常来,所以编写程序时,对于消费者需要及时捕获异常,并重试。
上一篇博客讲 Redis 的应用之分布式锁,如果加锁失败了,一般使用以下 3 种策略:
- 直接抛出异常,通知用户稍后重试。
这种方式比较适合 由用户直接发起的请求,用户看到错误对话框后,点击重试,起到人工延时的效果,由用户来决定是否重新发起请求。 - sleep ,稍后重试。
sleep 会阻塞当前的消息处理线程,会导致队列的后续消息处理出现延迟。 - 将请求转移至延时队列,稍后重试。
这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列,延后处理。
所谓的延时队列就是延时的消息队列。
2、延时队列的实现
**👀例 ** 使用 zset:
消息队列需要两个角色,生产者 和 消费者。生产者负责生产消息,入队,消费者负责监听这个队列,一发现有消息,就立即读出来。
通过 Redis 的 zset 排序集,将消息序列化成一个字符串,作为 zset 的 value,用设置好的时间戳作为 score,(没想到吧,score 还能这么用 🧐)消费者线程轮询 zset 获取到任务进行处理,多个线程可以保证可用性,万一一个线程挂了,还有其他线程可以继续处理。
需要先在 pom 文件中导入依赖,因为需要使用到 Json 序列化:
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.68</version>
</dependency>
代码:
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import redis.clients.jedis.Jedis;
import java.lang.reflect.Type;
import java.util.Set;
import java.util.UUID;
public class RedisDelayingQueue<T>{
static class TaskItem<T>{
public String id;
public T msg;
}
private Type TaskType=new TypeReference<TaskItem<T>>(){}.getType();
private Jedis jedis;
// 队列名称
private String queueKey;
public RedisDelayingQueue(Jedis jedis,String queueKey){
this.jedis=jedis;
this.queueKey=queueKey;
}
向队列中写入数据
public void delay(T msg){
TaskItem task = new TaskItem();
// 分配唯一的 uuid,保证消息的唯一性
task.id = UUID.randomUUID().toString();
task.msg = msg;
// fastjson 序列化
String s = JSON.toJSONString(task);
jedis.zadd(queueKey,System.currentTimeMillis() + 5000,s);
}
轮询获取消息队列中的消息:
public void loop(){
while (!Thread.interrupted()){
// 只取一条
Set values = jedis.zrangeByScore(queueKey,0,System.currentTimeMillis(),0,1);
// 如果获取的消息为空,睡眠过程中被中断,就接着获取下一条消息。
// 如果获取的消息为空,睡眠 0.5 s后跳出整个循环,即方法调用结束
if(values.isEmpty()){
try{
Thread.sleep(500);
} catch (InterruptedException e) {
break;
}
continue;
}
// 如果获取的消息不为空,尝试删除,如果删除成功,则 只有当前线程获取到了本条消息
String s = (String) values.iterator().next();
if(jedis.zrem(queueKey,s)>0){
TaskItem task = JSON.parseObject(s,TaskType);
this.handleMsg((T) task.msg);
}
}
}
public void handleMsg(T msg){
System.out.println(msg);
}
public static void main(String[] args) {
Jedis jedis = new Jedis();
final RedisDelayingQueue queue=new RedisDelayingQueue(jedis,"q-demo");
// 生产者
Thread producer = new Thread(){
public void run(){
for(int i=0; i<10; i++){
queue.delay("codehole"+i);
}
}
};
// 消费者
Thread consumer = new Thread(){
public void run(){
queue.loop();
}
};
producer.start();
consumer.start();
try{
producer.join();
Thread.sleep(6000);
consumer.interrupt();
consumer.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果:
可以看到,代码中使用 zrem 方法是多线程 多进程 争抢任务的关键,它的返回值决定了当前实例有没有抢到任务,因为 loop 方法可能会被多个线程 多个进程调用,通过 zrem 决定唯一的调用者。
以上代码是开启了两个线程,如果开启更多线程,也是 OK 的,可以保证多线程情况下只有一个线程处理了对应的消息:
(1)多个线程调用 zrangebyscore 获取到了一条消息
(2)线程 T1 准备开始删除消息,由于是原子操作,线程 T2 和 其他更多线程等待 T1 执行 zrem 删除消息后再执行 zrem
(3)线程 T1 删除了消息,返回删除成功标记 1,并对消息进行处理
(4)线程 T2 和 其他更多线程再执行 zrem ,由于消息已经被删除,所以所有的删除都会失败
如果想要优化代码,因为同一个任务被多个线程获取到后再使用 zrem 争抢,那些没抢到的线程都白取到了任务,造成了浪费,可以考虑使用 Lua 优化逻辑,将 zrangebyscore 和 zrem 一起放到服务器端,进行原子化操作。
Lua 脚本,:如果有超时的消息, 就删除, 并返回这条消息, 否则返回空字符串
String luaScript = "local resultArray = redis.call('zrangebyscore', KEYS[1], 0, ARGV[1], 'limit' , 0, 1)\n" +
"if #resultArray > 0 then\n" +
" if redis.call('zrem', KEYS[1], resultArray[1]) > 0 then\n" +
" return resultArray[1]\n" +
" else\n" +
" return ''\n" +
" end\n" +
"else\n" +
" return ''\n" +
"end";
jedis.eval(luaScript, ScriptOutputType.VALUE, new String[]{key}, String.valueOf(System.currentTimeMillis()));
Redis 延时队列的优势
- Redis zset支持高性能的 score 排序。
- Redis是在内存上进行操作的,速度非常快。
- Redis可以搭建集群,当消息很多时候,我们可以用集群来提高消息处理的速度,提高可用性。
- Redis具有持久化机制,当出现故障的时候,可以通过 AOF 和 RDB 方式来对数据进行恢复,保证了数据的可靠性。
Redis 延时队列的劣势
- 使用 Redis 实现的延时消息队列也存在数据持久化, 消息可靠性的问题。(如果对消息可靠性要求较高, 推荐使用 MQ 来实现。)
- 处理消息出现异常没有重试机制, 这些需要自己去实现,,包括重试次数的实现等。
- 没有 ACK 机制 ,例如在获取消息并已经删除了消息的情况下, 正在处理消息的时候,客户端崩溃了, 这条正在处理的这些消息就会丢失, MQ 是需要明确的返回一个值给 MQ 才会认为这个消息是被正确的消费了。
3、Redssion 实现延时队列
基于 Redis 的 Redisson 分布式延迟队列结构的 RDelayedQueue ,该 Java 对象 在实现了 RQueue 接口的基础上,提供了向队列 按要求 延迟添加项目 的功能。该功能可以用来实现 消息传送延迟 按几何增长 或 几何衰减的发送策略。
RQueue<String> distinationQueue = ...
RDelayedQueue<String> delayedQueue = getDelayedQueue(distinationQueue);
// 10 秒钟以后将消息发送到指定队列
delayedQueue.offer("msg1", 10, TimeUnit.SECONDS);
// 一分钟以后将消息发送到指定队列
delayedQueue.offer("msg2", 1, TimeUnit.MINUTES);
在该对象不再需要的情况下,应该主动销毁。仅在相关的 Redisson 对象也需要关闭的时候可以不用主动销毁。
RDelayedQueue<String> delayedQueue = ...
delayedQueue.destroy();
二、位图
在平时开发过程中,会有一些 布尔 boolean 类型数据需要存取,比如用户一年的签到记录,签了是 1,没签是 0,要记录 365 天。如果使用普通的 key/value,每个用户要记录 365 个,当用户上亿的时候,需要的存储空间是惊人的。为了解决这个问题,Redis 提供了位图数据结构,这样每天的签到记录只占据一个位,365 天就是 365 个位,46 个字节 (一个稍长一点的字符串) 就可以完全容纳下,这就大大节约了存储空间。
位图不是特殊的数据结构,它实际上是 byte 数组 ,它的内容其实就是普通的字符串。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 等将 byte 数组看成 byte数组 来处理。
1、基本使用
Redis 的 位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自动将位数组进行零扩充。
接下来使用 位操作 (而不是直接使用 set 指令)将字符串设置为 hello,需要先知道 hello 的 ASCII 码:
接下来用 redis-cli 设置第一个字符,也就是位数组的前 8 位,只需要设置值为 1 的位,需要注意的是,位数组的顺序 和 字符的位顺序是相反的,也就是说,h 字符只有 1、 2、4 位需要设置(位数从 0 算起)e 字符只有 9、10、13、15 位需要设置。
- 零存整取
- 以上的例子可以理解为 “零存整取”,同样也可以 “零存零取”、“整存零取”。“零存” 就是使用 setbit 对位值进行逐个设置,“整存” 就是使用字符串一次性填充所有位数组,覆盖掉旧值。
- 零存零取
- 整存零取
- 如果对应位的字节是不可打印字符,redis-cli 会显示该字符的 16 进制形式。
- 以上通过 setbit 设置 和 getbit 获取 指令。
Redis 提供了 位图统计指令 bitcount ,用来统计指定位置范围内 1 的个数 ,和 位图查找指令 bitpos,用来查找指定范围内出现的第一个 0 或 1。
比如,我们通过 bitcount 统计用户一共签到了多少天,通过 bitpos 指令查找用户从哪一天开始签到。如果指定了范围参数 [start,end] ,就可以统计在某个时间范围内用户签到了多少天,用户从某天以后的哪天开始签到。但是 start 和 end 参数都是字节索引,-1 表示最后一个字节,也就是说 指定的位范围必须是 8 的倍数,而不能任意指定。 我们无法直接计算某个月内用户签到了多少天,而必须要将这个月所覆盖的字节内容全部取出来,然后在内存中进行统计,这个非常繁琐。
使用实例: - 可以看到,先获取第一个字符 h 中的 1 的位数,再获取前两个字符中 1 的位数。
- 可以看到,先获取 第一个 0 位,再获取第一个 1 位,接下来获取从第 2 个字符 e 算起(到 e 为止),第一个 1 位,和 从第三个字符 l 算起(到 这个 l 为止),第一个 1 位。
如果想要一次操作多个位,就必须使用管道,不过 Redis 3.2 版本后新增了一个功能强大的指令,有了这条指令,不用管道也可以一次进行多个位的操作。
bitfield 有三个子指令,分别是 get / set / incrby ,它们都可以对指定位片段进行读写,但是最多只能处理 64 个连续的位,如果超过 64 位,就得使用多个子指令,bitfield 可以一次执行多个子指令。
可以看到,从第一个位开始取 4 个位,结果是无符号数 u;从第三个位开始取 3 个位,结果是无符号数;从第一个位开始取 4 个位,结果是有符号数 i;从第三个位开始取 3 个位,结果是有符号数。所谓的有符号数是指 获取的位数组中 第一个位是符号位,剩下的才是值。如果第一位是 1 ,那就是负数。无符号数表示非负数,没有符号位,获取的位数全部都是值 。有符号数最多可以获取 64 位,无符号数只能获取 63 位,因为 Redis 协议中的 integer 是有符号数,最大 64 位,不能传递 64 位无符号值。如果超出位数限制,Redis 会告知参数错误。
接下来一次执行多个子指令:
接下来使用 set 子指令 将第二个字符 e 改成 a,a 的 ACII 码是 97。
再来看第三个子指令 incrby ,它用来对指定范围的位 进行自增操作,可能出现溢出,如果增加了正数,会出现上溢;如果增加的是负数,会出现下溢。Redis 默认的处理方式是折返,如果出现了溢出,就将溢出的符号位丢掉,比如 8 位无符号数 255, 加 1 后会溢出,会全部变为 0 。如果是 8 位有符号数 127,加 1 后就会溢出变为 -128。使用示例:
可以看到,从第三个位开始,对接下来的 4 个无符号位 +1,六次操作后溢出折返了。
bitfield 指令提供了溢出策略子指令 overflow,用户可以选择溢出行为,默认是折返 wrap,还可以选择失败 fail 报错不执行,以及饱和截断 sat,超过了范围就停留在最大最小值。overflow 指令只影响接下来的第一条指令,这条指令执行完后 溢出策略会变成默认值折返 wrap。
默认的折返 wrap 方法处理有符号整数时,上溢将导致数字重新从最小的负数开始计算,而 下溢 将导致数字重新从最大的正数开始计算。 比如说, 如果我们对一个值为 127 的 i8 整数执行加一操作, 那么将得到结果 -128 。对于无符号整数,wrap 就像使用数值本身与能够被储存的最大无符号整数执行取模计算, 这也是 C 语言的标准行为。
- 失败不执行 fail
- 饱和截断 sat
2、优雅地使用 Redis 位图操作
比如,还是上面的例子,统计用户前 10 天签到天数,我们已经指定 bitcount 命令可以统计某个字符串中的比特位为 1 的数量,start 和 end 表示统计的范围,要注意 start 和 end 参数是字节的索引,而不是比特位的。我们限制需要统计的是 比特位索引从 0 到 9 的比特值位 1 的数量,所以直接使用 bitcount 命令显然是无法满足要求的。我们可以先拿到比特位索引从 0 到 9 所在的字节数组,要获取该数组的下标,需要将比特位索引除以 8 ,再向下取整即可,再将该字节数组解析成二进制形式,从而统计出比特位索引从 0 到 9 比特值为 1 的数量,将各比特位和1 做 与运算即可,第 index 位的比特值,需要先右移 7-index 位,再与 1 做与运算。
截取字节数组使用 Redis 的 getrange 命令,只要能够统计出截取处理的字节数组中比特位的值为 1 的数量,接下来再减去不包含在对应比特索引中比特位的值为 1 的数量。
比特索引 0 到 9 对应的是下标 0 到 1 的字节数组。
假设 0 到 1 的字节数组对应的比特值如下所示:
可以看到,标黑的刚好 10 位,也就是对应用户前 10 天签到天数。我们通过统计这 2 个字节中比特值为 1 的总数,再减去第二个字节的 2 到 7 位,就可以统计出用户前 10 天签到的天数。
💎 代码如下:
private static final int BIT_AMOUNT_IN_ONE_BYTE =8;
private Jedis jedis;
public int bitCountByBitIndex(String key, long startBitIndex, long endBitIndex) {
int startByteIndex = getByteIndexInTheBytes(startBitIndex);
int endByteIndex = getByteIndexInTheBytes(endBitIndex);
byte[] bytes = jedis.getrange(key.getBytes(), startByteIndex, endByteIndex);
int totalBitInBytes = getTotalBitInBytes(bytes);
int startBitIndexInFirstByte = getBitIndexInTheByte(startBitIndex);
int endBitIndexInLastByte = getBitIndexInTheByte(endBitIndex);
byte firstByte = bytes[0];
byte lastByte = bytes[bytes.length-1];
for(int i=7;i>(BIT_AMOUNT_IN_ONE_BYTE-1-startBitIndexInFirstByte);i--){
if(((firstByte>>i)&1)==1){
totalBitInBytes--;
}
}
for(int i=0;i<(BIT_AMOUNT_IN_ONE_BYTE-1-endBitIndexInLastByte);i++){
if(((lastByte>>i)&1)==1){
totalBitInBytes--;
}
}
return totalBitInBytes;
}
private int getTotalBitInBytes(byte[] bytes){
int count=0;
for(byte b:bytes){
for(int i = 0; i< BIT_AMOUNT_IN_ONE_BYTE; i++){
if(((b>>i)&1)==1){
count++;
}
}
}
return count;
}
private int getByteIndexInTheBytes(long offset){
return (int) offset/ BIT_AMOUNT_IN_ONE_BYTE;
}
private int getBitIndexInTheByte(long offset){
return (int)(offset-offset/ BIT_AMOUNT_IN_ONE_BYTE * BIT_AMOUNT_IN_ONE_BYTE);
}
现在假设我们有另一个需求,就是统计出用户注第3天、第5天、第10天、第20天、第30天的签到情况,注意这里要统计的是具体的登录情况,而不是签到的总天数。
一种最简单的方案就是通过循环调用 getbit 命令,查询出每一天的签到状态。由于 getbit 命令一次只能查询一个 offset 的 bit 值,这就意味着,使用这种方式的话,需要统计多少天的登录情况,就需要调用多少次 getbit命令,而每调用一次 getbit 命令,都需要一次网络请求(因为一般来讲,Redis 服务 跟 应用服务器 是不在同一台机器上的),所以,如果需要统计的天数比较多时,这种方式的性能是比较差的。
既要实现这个需求,又要兼顾性能,有两个个思路可以借鉴。一个思路是 通过解析字节数组,来获取对应比特位的 bit 值。另一个思路就是使用 Redis 的管道操作。
Redis 管道操作
✨Redis 官方对管道操作的介绍是:对于一次请求 / 响应,服务器能实现处理新的请求,即使旧的请求还未被响应。将多个命令发送到服务器,而不用等待回复,最后在一个步骤中读取该答复。✨
简而言之,管道操作类似批量操作,可以将多个 Redis 操作批量发送给 Redis,然后一次性地读取操作结果。接下来我们使用一个简单的例子来看看如何用管道操作来实现上述的功能。
@Test
public void testPinelined(){
Jedis client = new Jedis(host, port);
String key="user_111";
client.setbit(key,5,true);
client.setbit(key,20,true);
long[] offsets=new long[5];
offsets[0]=3;
offsets[1]=5;
offsets[2]=10;
offsets[3]=20;
offsets[4]=30;
Pipeline pipelined = client.pipelined();
for(long offset:offsets){
pipelined.getbit(key,offset);
}
List<Object> result = pipelined.syncAndReturnAll();
System.out.println(result);
}
我们先设置用户 第5天 和 第20天为已签到状态,然后使用管道操作批量读取用户注册后第3天、第5天、第10天、第20天、第30天的签到情况,因此正确的输出应该是除了第5天 进入 第20天为已签到外,其他都为未签到状态。
前面说了使用管道操作的好处就是可以将多个操作批量发送给 Redis,然后一次性读取所有命令的结果,因此可以减少网络请求的次数,在命令比较多的情况下可以大大提升性能,如果使用的 Redis 是单节点的,单节点的 Redis 对管道操作支持比较好,但是如果是 Redis 集群,则有些客户端没有提供相关的管道操作,如常用的 Jedis 客户端就没有提供 Redis 集群模式下的管道操作。 因此如果你使用的是Redis集群,可能无法直接使用管道操作实现上述功能。
第二种方法是 基于字节数组解析,先获取该key值对应的字节数组,这可以通过get命令来实现。然后再计算出对应的offset在字节数组中的索引,以及在某个字节中的比特位索引,接下来就可以统计出该比特位的bit值了。
以 offset 为 30 为例,只需要将 30 除以 8(一个字节有8比特),再向下取整,就可以计算出 offset 为 30 的比特位在字节数组中的下标了,在这里 30 除以 8 向下取整是 3,即offset为 30 的比特位在字节数组中的下标为 3(下标是从0开始的)。要计算 offset 在对应的字节中的比特位下标也很简单,只需要将 offset 对 8 取模就行了,比如 30 对 8 取模的值为 6,说明 offset 为30 的比特位 在 对应的字节中的比特位 下标为 6(这里的下标也是从 0 开始的)。找到了某个 offset 在字节数组中的下标以及在字节中的比特位下标,就可通过右移的方式计算出该比特位的值了。
💎 代码:
private static final int BIT_AMOUNT_IN_ONE_BYTE =8;
public boolean[] getBits(String key, long[] offsets) {
int offsetLen = offsets.length;
boolean[] result=new boolean[offsetLen];
byte[] bytes = this.get(key.getBytes());
for(int i = 0; i< offsetLen; i++){
long offset=offsets[i];
int byteIndexOfTheBytes = getByteIndexInTheBytes(offset);
int bitIndexOfTheByte = getBitIndexInTheByte(offset);
byte b = bytes[byteIndexOfTheBytes];
int shiftCount = getRightShiftStep(bitIndexOfTheByte);
result[i]=((b>>shiftCount)&1)==1;
}
return result;
}
private int getByteIndexInTheBytes(long offset){
return (int) offset/ BIT_AMOUNT_IN_ONE_BYTE;
}
private int getBitIndexInTheByte(long offset){
return (int) offset%BIT_AMOUNT_IN_ONE_BYTE;
}
private static int getRightShiftStep(int bitIndexOfTheByte){
return BIT_AMOUNT_IN_ONE_BYTE -bitIndexOfTheByte-1;
}
当然这种方式也是存在隐患的。因为测试的数据的 offset都比较小,就拿我们的例子来说,最大的 offset 也才到 30,因此通过 get 命令返回的字节数组比较小,没什么大问题。如果我们的 offset 比较大,比如是百万级别甚至千万级别,这种方式就会有问题了,因为这个时候字节数组会非常大,可能达到几十兆甚至几百兆,这么大的数据通过网络传输需要非常久的时间,也可能造成服务器内存溢出。