Redis解读(3):Redis分布式锁、消息队列、操作位图进阶应用
Redis 做分布式锁
分布式锁也算是 Redis 比较常见的使用场景
问题场景:
例如一个简单的用户操作,一个线城去修改用户的状态,首先从数据库中读出用户的状态,然后
在内存中进行修改,修改完成后,再存回去。在单线程中,这个操作没有问题,但是在多线程
中,由于读取、修改、存 这是三个操作,不是原子操作,所以在多线程中,这样会出问题。
对于这种问题,我们可以使用分布式锁来限制程序的并发执行。
1.基本用法
分布式锁实现的思路很简单,就是进来一个线城先占位,当别的线城进来操作时,发现已经有人占位
了,就会放弃或者稍后再试。
在 Redis 中,占位一般使用 setnx 指令,先进来的线城先占位,线城的操作执行完成后,再调用 del 指
令释放位子。
根据上面的思路,我们写出的代码如下:
package org.taoguoguo.distributed.lock;
import org.taoguoguo.redis.Redis;
/**
* @author taoguoguo
* @description LockTest
* @website https://www.cnblogs.com/doondo
* @create 2021-04-11 16:19
*/
public class LockTest {
public static void main(String[] args) {
Redis redis = new Redis();
redis.execute(jedis -> {
Long setnx = jedis.setnx("lockName", "lockValue");
if(1 == setnx){
//没有线程占位,执行业务代码
jedis.set("name","taoguoguo");
System.out.println(jedis.get("name"));
//释放资源
jedis.del("lockName");
}else{
//有线程占位,停止/暂缓 操作
}
});
}
}
上面的代码存在一个小小问题:如果代码业务执行的过程中抛异常或者挂了,这样会导致 del 指令没有
被调用,这样,lockName 无法释放,后面来的请求全部堵塞在这里,锁也永远得不到释放。
要解决这个问题,我们可以给锁添加一个过期时间,确保锁在一定的时间之后,能够得到释放。改进后
的代码如下:
package org.taoguoguo.distributed.lock;
import org.taoguoguo.redis.Redis;
/**
* @author taoguoguo
* @description LockTest
* @website https://www.cnblogs.com/doondo
* @create 2021-04-11 16:19
*/
public class LockTest {
public static void main(String[] args) {
Redis redis = new Redis();
redis.execute(jedis -> {
Long setnx = jedis.setnx("lockName", "lockValue");
if(1 == setnx){
//给锁添加一个过期时间,防止应用在运行过程中抛出异常导致锁无法及时得到释放
jedis.expire("lockName",5);
//没有线程占位,执行业务代码
jedis.set("name","taoguoguo");
System.out.println(jedis.get("name"));
//释放资源
jedis.del("lockName");
}else{
//有线程占位,停止/暂缓 操作
}
});
}
}
这样改造之后,还有一个问题,就是在获取锁和设置过期时间之间如果如果服务器突然挂掉了,这个时
候锁被占用,无法及时得到释放,也会造成死锁,因为获取锁和设置过期时间是两个操作,不具备原子
性。
为了解决这个问题,从 Redis2.8 开始,setnx 和 expire 可以通过一个命令一起来执行了,我们对上述
代码再做改进:
package org.taoguoguo.distributed.lock;
import org.taoguoguo.redis.Redis;
import redis.clients.jedis.params.SetParams;
/**
* @author taoguoguo
* @description LockTest
* @website https://www.cnblogs.com/doondo
* @create 2021-04-11 16:19
*/
public class LockTest {
public static void main(String[] args) {
Redis redis = new Redis();
redis.execute(jedis -> {
String set = jedis.set("lockName", "lockValue", new SetParams().nx().ex(5));
if(set != null && "OK".equals(set)){
//没有线程占位,执行业务代码
jedis.set("name","taoguoguo");
System.out.println(jedis.get("name"));
//释放资源
jedis.del("lockName");
}else{
//有线程占位,停止/暂缓 操作
}
});
}
}
2.解决超时问题
问题场景:
为了防止业务代码在执行的时候抛出异常,我们给每一个锁添加了一个超时时间,超时之后,锁会被自
动释放,但是这也带来了一个新的问题:如果要执行的业务非常耗时,可能会出现紊乱。举个例子:第
一个线程首先获取到锁,然后开始执行业务代码,但是业务代码比较耗时,执行了 8 秒,这样,会在第
一个线程的任务还未执行成功锁就会被释放了,此时第二个线程会获取到锁开始执行,在第二个线程刚
执行了 3 秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,它释放的第二个线程的
锁,释放之后,第三个线程进来。
对于这个问题,我们可以从两个角度入手:
- 尽量避免在获取锁之后,执行耗时操作。
- 可以在锁上面做文章,将锁的 value 设置为一个随机字符串,每次释放锁的时候,都去比较随机
字符串是否一致,如果一致,再去释放,否则,不释放。
对于第二种方案,由于释放锁的时候,要去查看锁的 value,第二个比较 value 的值是否正确,第三步
释放锁,有三个步骤,很明显三个步骤不具备原子性,为了解决这个问题,我们得引入 Lua 脚本。
Lua 脚本的优势:
-
使用方便,Redis 中内置了对 Lua 脚本的支持。
-
Lua 脚本可以在 Redis 服务端原子的执行多个 Redis 命令。
-
由于网络在很大程度上会影响到 Redis 性能,而使用 Lua 脚本可以让多个命令一次执行,可以有
效解决网络给 Redis 带来的性能问题。
在 Redis 中,使用 Lua 脚本,大致上两种思路:
- 提前在 Redis 服务端写好 Lua 脚本,然后在 Java 客户端去调用脚本(推荐)。
- 可以直接在 Java 端去写 Lua 脚本,写好之后,需要执行时,每次将脚本发送到 Redis 上去执行。
首先在 Redis 服务端创建 Lua 脚本,内容如下:
if redis.call("get",KEYS[1])==ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
接下来,可以给 Lua 脚本求一个 SHA1 和,命令如下:
cat releasewherevalueequal.lua | redis-cli -a 123456 script load --pipe
script load 这个命令会在 Redis 服务器中缓存 Lua 脚本,并返回脚本内容的 SHA1 校验和,然后在 Java 端调用时,传入 SHA1 校验和作为参数,这样 Redis 服务端就知道执行哪个脚本了。
接下来,在 Java 端调用这个脚本。
package org.taoguoguo.redis;
import redis.clients.jedis.params.SetParams;
import java.util.Arrays;
import java.util.UUID;
/**
* @author taoguoguo
* @description LuaTest
* @website https://www.cnblogs.com/doondo
* @create 2021-04-11 17:56
*/
public class LuaTest {
public static void main(String[] args) {
Redis redis = new Redis();
for (int i=0; i<10; i++){
redis.execute(jedis -> {
//1.先获取一个随机字符串
String value = UUID.randomUUID().toString();
//2.获取锁
String lock = jedis.set("lockName", value, new SetParams().nx().ex(5));
//3.判断是否成功拿到锁
if (lock != null && "OK".equals(lock)) {
//4.具体的业务操作
jedis.set("site", "https://www.cnblogs.com/doondo");
String site = jedis.get("site");
System.out.println(site);
//5.释放锁
jedis.evalsha("b8059ba43af6ffe8bed3db65bac35d452f8115d8", Arrays.asList("lockName"), Arrays.asList(value));
} else {
System.out.println("没拿到锁");
}
});
}
}
}
Redis 做消息队列
我们平时说到消息队列,一般都是指 RabbitMQ、RocketMQ、ActiveMQ 以及大数据里边的 Kafka,
这些是我们比较常见的消息中间件,也是非常专业的消息中间件,作为专业的中间件,它里边提供了许
多功能。
但是,当我们需要使用消息中间件的时候,并非每次都需要非常专业的消息中间件,假如我们只有一个
消息队列,只有一个消费者,那就没有必要去使用上面这些专业的消息中间件,这种情况我们可以直接
使用 Redis 来做消息队列。
Redis 的消息队列不是特别专业,他没有很多高级特性,适用简单的场景,如果对于消息可靠性有着极
高的追求,那么不适合使用 Redis 做消息队列。
1.消息队列
Redis 做消息队列,使用它里边的 List 数据结构就可以实现,我们可以使用 lpush/rpush 操作来实现入
队,然后使用 lpop/rpop 来实现出队。
回顾一下:
在客户端(例如 Java 端),我们会维护一个死循环来不停的从队列中读取消息,并处理,如果队列中
有消息,则直接获取到,如果没有消息,就会陷入死循环,直到下一次有消息进入,这种死循环会造成
大量的资源浪费,这个时候,我们可以使用之前讲的 blpop/brpop 。
2.延迟消息队列
延迟队列可以通过 zset 来实现,因为 zset 中有一个 score,我们可以把时间作为 score,将 value 存到
redis 中,然后通过轮询的方式,去不断的读取消息出来。
首先,如果消息是一个字符串,直接发送即可,如果是一个对象,则需要对对象进行序列化,这里我们
使用 JSON 来实现序列化和反序列化。
所以,首先在项目中,添加 JSON 依赖:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.10.3</version>
</dependency>
接下来,构造一个消息对象:
package org.taoguoguo.message;
/**
* @author taoguoguo
* @description RedisMessage 消息对象
* @website https://www.cnblogs.com/doondo
* @create 2021-04-12 20:33
*/
public class RedisMessage {
//消息ID
private String id;
//消息体
private Object data;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
@Override
public String toString() {
return "RedisMessage{" +
"id='" + id + '\'' +
", data=" + data +
'}';
}
}
接下来封装一个消息队列:
package org.taoguoguo.message;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import redis.clients.jedis.Jedis;
import java.util.Date;
import java.util.Set;
import java.util.UUID;
/**
* @author taoguoguo
* @description DelayMessageQueue 延迟消息队列
* @website https://www.cnblogs.com/doondo
* @create 2021-04-12 20:35
*/
public class DelayMessageQueue {
private Jedis jedis;
//消息队列队列名
private String queue;
public DelayMessageQueue(Jedis jedis, String queue) {
this.jedis = jedis;
this.queue = queue;
}
/**
* 消息入队
* @param data 要发送的消息
*/
public void queue(Object data){
try {
//1.构造一个Redis消息对象
RedisMessage message = new RedisMessage();
message.setId(UUID.randomUUID().toString());
message.setData(data);
//2.序列化
String jsonMessage = new ObjectMapper().writeValueAsString(message);
System.out.println("Redis Message publish: " + new Date());
//消息发送,score 延迟 5 秒
jedis.zadd(queue, System.currentTimeMillis()+5000,jsonMessage);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
/**
* 消息消费
*/
public void loop(){
//当前线程未被打断 一直监听
while (!Thread.interrupted()){
//读取 score 在 0 到当前时间戳之间的消息 一次读取一条,偏移量为0
Set<String> messageSet = jedis.zrangeByScore(queue, 0, System.currentTimeMillis(), 0, 1);
if(messageSet.isEmpty()){
try {
//如果消息是空的,则休息 500 毫秒然后继续
Thread.sleep(500);
} catch (InterruptedException e) {
//如果抛出异常 退出循环
break;
}
continue;
}
//如果读取到了消息,则直接读取出来
String messageStr = messageSet.iterator().next();
if(jedis.zrem(queue,messageStr) > 0){
//消息存在,并且消费成功
try {
RedisMessage redisMessage = new ObjectMapper().readValue(messageStr, RedisMessage.class);
System.out.println("Redis Message receive: " + new Date() + redisMessage);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
}
测试:
package org.taoguoguo.message;
import org.taoguoguo.redis.Redis;
/**
* @author taoguoguo
* @description DelayMessageTest
* @website https://www.cnblogs.com/doondo
* @create 2021-04-12 21:20
*/
public class DelayMessageTest {
public static void main(String[] args) {
Redis redis = new Redis();
redis.execute(jedis -> {
//构造一个消息队列
DelayMessageQueue queue = new DelayMessageQueue(jedis, "taoguoguo-delay-queue");
//构造消息生产者
Thread producer = new Thread(){
@Override
public void run() {
for(int i=0;i<5;i++){
queue.queue("https://www.cnblogs.com/doondo>>>>>"+i);
}
}
};
//构造消息消费者
Thread consumer = new Thread(){
@Override
public void run() {
queue.loop();
}
};
//启动
producer.start();
consumer.start();
//消费完成后,停止程序,时间大于消费时间
try {
Thread.sleep(10000);
consumer.interrupt();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
}
Redis操作位图
1.基本介绍
用户一年的签到记录,如果你用 string 类型来存储,那你需要 365 个 key/value,操作起来麻烦。通过位图可以有效的简化这个操作。
它的统计很简单:01111000111
每天的记录占一个位,365 天就是 365 个位,大概 46 个字节,这样可以有效的节省存储空间,如果有一天想要统计用户一共签到了多少天,统计 1 的个数即可。
对于位图的操作,可以直接操作对应的字符串(get/set),可以直接操作位(getbit/setbit)
2.基本操作
2.1零存整取
存的时候操作的是位,获取的时候是获取整个字符串
例如存储一个 Java 字符串:
字符 | ASCII | 二进制 |
---|---|---|
J | 74 | 01001010 |
a | 97 | 01100001 |
v | 118 | 01110110 |
2.2整存零取
存一个字符串进去,但是通过位操作获取字符串。
3.统计
例如签到记录:01111000111
1 表示签到的天,0 表示没签到,统计总的签到天数:可以使用 bitcount。
bitcount 中,可以统计的起始位置,但是注意,这个起始位置是指字符的起始位置而不是 bit 的起始位置。
除了 bitcount 之外,还有一个 bitpos。bitpos 可以用来统计在指定范围内出现的第一个 1 或者 0 的位置,这个命令中的起始和结束位置都是字符索引,不是 bit 索引,一定要注意。
4.Bit 批处理
在 Redis 3.2 之后,新加了一个功能叫做 bitfiled ,可以对 bit 进行批量操作。
例如:
BITFIELD name get u4 0
表示获取 name 中的位,从 0 开始获取,获取 4 个位,返回一个无符号数字。
- u 表示无符号数字
- i 表示有符号数字,有符号的话,第一个符号就表示符号位,1 表示是一个负数。
BITFIELD 也可以一次执行多个操作。
GET(对于结果不太明白的,学习一下计算机中 位与字节、以及进制之间的关系)
可以一次性进行多个GET
SET:
用无符号的 98 转成的 8 位二进制数字,代替从第 8 位开始接下来的 8 位数字。
INCRBY:
对置顶范围进行自增操作,自增操作可能会出现溢出,既可能是向上溢出,也可能是向下溢出。Redis 中对于溢出的处理方案是折返。8 位无符号数 255 加 1 溢出变为 0;8 位有符号数 127,加 1 变为 - 128。
也可以修改默认的溢出策略,可以改为 fail ,表示执行失败。
BITFIELD name overflow fail incrby u2 6 1
sat 表示留在在最大/最小值。
BITFIELD name overflow sat incrby u2 6 1