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 脚本,大致上两种思路:

  1. 提前在 Redis 服务端写好 Lua 脚本,然后在 Java 客户端去调用脚本(推荐)。
  2. 可以直接在 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 依赖:


    com.fasterxml.jackson.core
    jackson-databind
    2.10.3

接下来,构造一个消息对象:

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 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