Redis(四)实际应用

1 Redis客户端

1.1 客户端与服务通讯

Redis监听默认6379的端口号,可以通过TCP方式建立连接。
服务端约定了一种特殊的消息格式,每个命令都是以\r\n(CRLF 回车 + 换行)结尾。这种编码格式我们之前在AOF文件里面见到了,叫做Redis Serialization Protocol(RESP,Redis序列化协议),发消息,收消息或者响应消息需要按照这种格式编码。这种编码容易实现,解析快,可读性强。
Redis 6.0新特性里面说的RESP协议升级到了3.0版本,其实就是对于服务端和客户端可以接收的消息进行了升级扩展,比如客户端缓存的功能。
这种编码的本质在抓包之后可以知道,就是将命令,参数和总长度用/r/n连接起来,这样的话,可以用java代码自己实现一个Redis客户端。

  1. 建立Socket连接
  2. OutputStream写入数据(发送命令到服务端)
  3. InputStream读取数据(从服务端接收数据)
public class MyClient {
	private Socket socket;
    private OutputStream write;
    private InputStream read;

    public MyClient(String host, int port) throws IOException {
        socket = new Socket(host, port);
        write = socket.getOutputStream();
        read = socket.getInputStream();
    }

    /**
     * 实现set方法
     *
     * @param key 键
     * @param val 值
     * @throws IOException 可能出现IO错误
     */
    public void set(String key, String val) throws IOException {
        StringBuffer stringBuffer = new StringBuffer();
        //代表三个参数
        stringBuffer.append("*3").append("\r\n");
        //第一个参数(set)的长度
        stringBuffer.append("$3").append("\r\n");
        //第一个参数的内容
        stringBuffer.append("SET").append("\r\n");
        //第二个参数key的长度(不定长,动态获取长度)
        stringBuffer.append("$").append(key.getBytes().length).append("\r\n");
        //第二个参数key的内容
        stringBuffer.append(key).append("\r\n");
        //第三个参数value的长度(不定长,动态获取长度)
        stringBuffer.append("$").append(val.getBytes().length).append("\r\n");
        //第三个参数value的内容
        stringBuffer.append(val).append("\r\n");
        //发送命令
        send(stringBuffer.toString());
    }

    /**
     * 实现get方法
     * 
     * @param key 键
     * @throws IOException 可能抛出IO异常
     */
    public void get(String key) throws IOException {
        StringBuffer stringBuffer = new StringBuffer();
        //代表2个参数
        stringBuffer.append("*2").append("/r/n");
        //第一个参数(get)的长度
        stringBuffer.append("$3").append("/r/n");
        //第一个参数的内容
        stringBuffer.append("GET").append("/r/n");
        //第二个参数key的长度
        stringBuffer.append("$").append(key.getBytes().length).append("/r/n");
        //第二个参数内容
        stringBuffer.append(key).append("/r/n");
        
        send(stringBuffer.toString());
    }

    /**
     * 发送命令
     * 
     * @param command 命令内容
     * @throws IOException 可能抛出IO错误
     */
    private void send(String command) throws IOException{
        write.write(command.getBytes());
        byte[] bytes = new byte[1024];
        read.read(bytes);
        System.out.println(new String(bytes));
    }

    public static void main(String[] args) throws IOException{
        MyClient client = new MyClient("127.0.0.1", 6379);
        client.set("command", "helloWorld");
        client.get("command");
    }
}

使用这种协议,可以实现所有的Redis命令。

1.2 常用客户端

官网推荐的java客户端有3个,Jedis,Redisson和Luttuce。

配置作用
Jedis体系非常小,但功能很完善
Lettuce高级客户端,支持线程安全,一部,反应式编程,支持集群,哨兵,pipeline,编解码
Redisson基于Redis服务实现的java分布式可扩展的数据结构

Spring操作Redis提供了一个模板方法,RedisTemplate。
Spring定义了一个连接工厂接口,RedisConnectionFactory。这个接口有很多实现,例如:JedisConnectionFactory,JredisConnectionFactory,LettuceConnectionFactory,SrpConnectionFactory,这样看,Spring是对其他客户端做出一个封装,封装类就是RedisTemplate。
在Spring Boot 2.x版本之前,RedisTemplate默认使用Jedis。2.x版本之后,默认使用Lettuce。

1.2.1 Jedis

1.2.1.1 Jedis功能特性

Jedis是最熟悉的客户端,如果不使用RedisTemplate,就可以直接创建Jedis的连接。

public static void main(String[] args) {
	Jedis jedis = new Jedis("127.0.0.1", 6379);
	jedis.set("key", "value");
	jedis.gey("key");
	jedis.close();
}

Jedis有一个问题,多线程使用一个连接的时候线程不安全,可以通过创建连接池来解决这个问题。
Jedis的连接池有三个实现:JedisPool,ShardJedisPool,JedisSentinelPool都是用getResource从连接池获取一个连接。

/**
 * 普通连接池
 */
public static void ordinaryPool() {
    JedisPool pool = new JedisPool("127.0.0.1", 6379);
    Jedis jedis = pool.getResource();
    jedis.set("testKey", "testValue");
    System.out.println(jedis.get("testKey"));
}

/**
 * 分片连接池
 */
public static void shardedPool() {
    JedisPoolConfig poolConfig = new JedisPoolConfig();
    JedisShardInfo shardInfo = new JedisShardInfo("127.0.0.1", 6379);
    List<JedisShardInfo> infoList = Arrays.asList(shardInfo);
    ShardedJedisPool jedisPool = new ShardedJedisPool(poolConfig, infoList);
    ShardedJedis jedis = jedisPool.getResource();
    jedis.set("shardKey", "shardValue");
    System.out.println(jedis.get("shardKey"));
}

/**
 1. 哨兵连接池
 */
public static void sentinelPool() {
    String masterName = "redis-master";
    Set<String> sentinels = new HashSet<>();
    sentinels.add("127.0.0.1:6379");
    sentinels.add("127.0.0.2:6379");
    sentinels.add("127.0.0.3:6379");
    
    JedisSentinelPool pool = new JedisSentinelPool(masterName, sentinels);
    pool.getResource().set("sentinelKey", "sentinelValue");
    System.out.println(pool.getResource().get("sentinelKey"));
}

public static void Cluster() throws IOException {
    // 不论连接的是主机,丛机或是连几台机器都是一样的效果,cluster都能发现全部
    HostAndPort hp1 = new HostAndPort("192.168.44.181",7291);
    HostAndPort hp2 = new HostAndPort("192.168.44.181",7292);
    HostAndPort hp3 = new HostAndPort("192.168.44.181",7293);
    HostAndPort hp4 = new HostAndPort("192.168.44.181",7294);
    HostAndPort hp5 = new HostAndPort("192.168.44.181",7295);
    HostAndPort hp6 = new HostAndPort("192.168.44.181",7296);

    Set nodes = new HashSet<HostAndPort>();
    nodes.add(hp1);
    nodes.add(hp2);
    nodes.add(hp3);
    nodes.add(hp4);
    nodes.add(hp5);
    nodes.add(hp6);

    JedisCluster cluster = new JedisCluster(nodes);
    cluster.set("key", "value");
    System.out.println(cluster.get("key"));;
    cluster.close();
}

Jedis的功能提供比较完善,Redis官方的特性全部支持,比如发布订阅,事务,Lua脚本,客户端分片,哨兵,集群,pipeline等等。
Jedis连接Sentinel需要配置所有的哨兵地址,Cluster连接哨兵只需要配置任何一个master或者slave的地址就可以。

1.2.1.2 Sentinel获取连接原理

在SentinelPool的源代码中,其构造方法最终调用了

HostAndPort master = this.initSentinels(sentinels, masterName);

查看此方法内部结构

private HostAndPort initSentinels(Set<String> sentinels, String masterName) {
    HostAndPort master = null;
    boolean sentinelAvailable = false;
    this.log.info("Trying to find master from available Sentinels...");
    Iterator var5 = sentinels.iterator();

    String sentinel;
    HostAndPort hap;
    //假如有多个sentinel,进行循环处理
    while(var5.hasNext()) {
        sentinel = (String)var5.next();
        //host:port表示的sentinel地址转化为一个HostAndPort对象
        hap = HostAndPort.parseString(sentinel);
        this.log.fine("Connecting to Sentinel " + hap);
        Jedis jedis = null;

        try {
        	//连接到Sentinel
            jedis = new Jedis(hap.getHost(), hap.getPort());
            //根据masterName获取到对应的master地址,返回一个List
            //masterAddr[0]是host,masterAddr[1]是port
            List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);
            sentinelAvailable = true;
            if (masterAddr != null && masterAddr.size() == 2) {
            	//如果能够在任何一个sentinel中获取到master,就终止循环
                master = this.toHostAndPort(masterAddr);
                this.log.fine("Found Redis master at " + master);
                break;
            }

            this.log.warning("Can not get master addr, master name: " + masterName + ". Sentinel: " + hap + ".");
        } catch (JedisException var13) {
            this.log.warning("Cannot get master address from sentinel running @ " + hap + ". Reason: " + var13 + ". Trying next one.");
        } finally {
            if (jedis != null) {
                jedis.close();
            }

        }
    }

	//假如master最后还是为空,可能有两种情况。
	//第一种,存活的sentinel没有监控到master节点
	//第二种,全部sentinel都掉线了
    if (master == null) {
        if (sentinelAvailable) {
            throw new JedisException("Can connect to sentinel, but " + masterName + " seems to be not monitored...");
        } else {
            throw new JedisConnectionException("All sentinels down, cannot determine where is " + masterName + " master is running...");
        }
    } else {
        this.log.info("Redis master running at " + master + ", starting Sentinel listeners...");
        var5 = sentinels.iterator();

		//为每一个sentinel启动一个masterListener的监听
		//masterListener本身是一个线程,它会去订阅sentinel上关于master节点地址改变的消息。
        while(var5.hasNext()) {
            sentinel = (String)var5.next();
            hap = HostAndPort.parseString(sentinel);
            JedisSentinelPool.MasterListener masterListener = new JedisSentinelPool.MasterListener(masterName, hap.getHost(), hap.getPort());
            masterListener.setDaemon(true);
            this.masterListeners.add(masterListener);
            masterListener.start();
        }

        return master;
    }
}
1.2.1.3 Cluster获取连接原理

使用Jedis连接Cluster的时候,我们只需要连接到任意一个或者多个Redis Group中的实例地址,那如何获得Redis Master实例?
为了避免get,set的时候发生重定向错误,需要将slot和Redis节点的关系保存起来,在本地计算slot,就可以获得Redis节点信息。
注意,16384个虚拟槽slots数量是写死的,不能修改,那么为什么要这么设计呢?

  1. 在服务端表示16384个位,只需要2KB的大小(每个Group维护一个位数组,在16384bit里面把对应下标的值改为1,就代表slot由当前节点负责),再大的话,获取slots信息有点浪费通信资源。
  2. 一般来说集群的节点数不会很大,16384个slots已经足够使用。

在客户端中,储存slot和redis连接池的关系最为关键。

  1. 程序启动初始化集群环境,读取配置文件中的节点配置,无论是主从,无论多少个,只拿第一个,获取redis连接实例。
  2. 通过discoverClusterNodesAndSlots方法,用获取的redis连接实例执行clusterSlots()方法,实际执行redis服务端cluster slots命令,获取虚拟槽信息。该集合的基本信息为[long,long,list,list],第一,二个元素是该节点负责槽点的起始位置,第三个元素是主节点信息,第四个元素为主节点对应的从节点信息。该list的基本信息为[string,int,string],第一个为host信息,第二个为port信息,第三个为唯一id。
private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig, String password) {
    Iterator var4 = startNodes.iterator();

    while(var4.hasNext()) {
    	//获取一个Jedis实例
        HostAndPort hostAndPort = (HostAndPort)var4.next();
        Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort());
        if (password != null) {
            jedis.auth(password);
        }

        try {
        	//获取Redis节点和Slot虚拟插槽
            this.cache.discoverClusterNodesAndSlots(jedis);
            //直接跳出循环
            break;
        } catch (JedisConnectionException var11) {
        } finally {
            if (jedis != null) {
                jedis.close();
            }

        }
    }

}
  1. 获取有关节点的槽点信息后,调用getAssignedSlotArray(slotinfo)来获取多有的槽点值。
  2. 再获取主节点的地址信息,调用generateHostAndPort(hostinfo)方法,生成一个hostAndPort对象。
public void discoverClusterNodesAndSlots(Jedis jedis) {
    this.w.lock();

    try {
        this.reset();
        //获取Redis节点集合
        //格式:slot起始位置,结束位置,master节点(list:ip,port,id),slave节点(list:ip,port,id)
        List<Object> slots = jedis.clusterSlots();
        Iterator var3 = slots.iterator();

		//遍历全部节点
        while(true) {
            List slotInfo;
            do {
                if (!var3.hasNext()) {
                    return;
                }

                Object slotInfoObj = var3.next();
                //slotInfo[槽开始,槽结束,主节点端口,从节点端口]
                slotInfo = (List)slotInfoObj;
                //如果你的长度小于2,那么证明你没有分配到槽节点
            } while(slotInfo.size() <= 2);

			//获取到分配给当前mater节点的数据槽
            List<Integer> slotNums = this.getAssignedSlotArray(slotInfo);
            int size = slotInfo.size();

			//获取第3位和第4位的主从端口号
            for(int i = 2; i < size; ++i) {
                List<Object> hostInfos = (List)slotInfo.get(i);
                if (hostInfos.size() > 0) {
                	//根据端口号生成HostAndPort实例
                    HostAndPort targetNode = this.generateHostAndPort(hostInfos);
                    //存储IP:port和连接池的关系
                    this.setupNodeIfNotExist(targetNode);
                    if (i == 2) {
                    	//把slot和jedisPool缓存起来,一共16384个对应关系
                    	//key是slot下标,value是连接池
                    	//不同的master有自己的连接池
                        this.assignSlotsToNode(slotNums, targetNode);
                    }
                }
            }
        }
    } finally {
        this.w.unlock();
    }
}
  1. 在assignSlotsToNode方法中,再根据节点地址信息来设置节点对应的JedisPool,即设置Map<String, JedisPool> nodes的值。
public JedisPool setupNodeIfNotExist(HostAndPort node) {
    this.w.lock();

    JedisPool var5;
    try {
    	//根据HostAndPort 解析出节点在Map中的key,即 ip:port
        String nodeKey = getNodeKey(node);
        //再根据key从缓存中查询对应的jedispool实例
        JedisPool existingPool = (JedisPool)this.nodes.get(nodeKey);
        JedisPool nodePool;
        if (existingPool != null) {
            nodePool = existingPool;
            return nodePool;
        }
        
		//如果没有jedisPool实例,就创建一个实例,放入缓存中
        nodePool = new JedisPool(this.poolConfig, node.getHost(), node.getPort(), this.connectionTimeout, this.soTimeout, this.password, 0, (String)null, false, (SSLSocketFactory)null, (SSLParameters)null, (HostnameVerifier)null);
        //在节点中放入ip:port和连接池的关系。
        this.nodes.put(nodeKey, nodePool);
        var5 = nodePool;
    } finally {
        this.w.unlock();
    }

    return var5;
}
  1. 判断若此时节点信息为主节点信息时,则调用assignSlotsToNodes方法,设置每一个槽点值对应的连接池(slave不需要连接),即设置Map<Integer, JedisPool> slots的值。这个Map有16384个·key,key对应的value是一个连接池信息,有几个Redis Group(或者说有几个master),就有几个不同的连接池。
if (i == 2) {
	//把slot和jedisPool缓存起来,一共16384个对应关系
    //key是slot下标,value是连接池
    //不同的master有自己的连接池
	this.assignSlotsToNode(slotNums, targetNode);
}

获取slot和redis实例的对应关系之后,接下来就是从集群环境存取值。
Jedis集群模式下所有命令都要调用这个方法:
JedisClusterCommand#runWithRetries的116行

connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));

其执行步骤如下:

  1. 把key作为参数,执行CRC16算法,获取key对应的slot值。
  2. 通过该slot值,去slots的map集合中获取jedisPool实例。
  3. 通过jedisPool实例获取jedis实例,最终完成redis数据存取工作。

因为Jedis支持Lua脚本,所以Jedis也实现了分布式锁。

1.2.1.4 Jedis实现分布式锁

分布式锁的实现有很多种,越安全的代码实现就越复杂。
分布式锁的基本要求:

  1. 互斥性:只有一个客户端能够持有锁
  2. 不会产生死锁:即使持有锁的客户端崩溃,也能保证后续其他客户端可以获取锁。
  3. 唯一解锁:只有持有这把锁的客户端才能解锁。

可以根据此思路先尝试实现一个:

public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        //set支持多个参数NX(not exist),XX(not exist),EX(not exist),PX(not exist)
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        if(LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

参数作用:

  1. lockKey:Redis Key,谁成功添加这个key,就代表谁获取锁成功。
  2. requestId:客户端ID(被设置成value),如果要保证只有加锁的客户端才能释放锁,就必须获得客户端ID,目的是保证第三点
  3. SET_IF_NOT_EXIST:在命令中加上NX,目的是保证第一点,产生互斥。
  4. SET_WITH_EXPIRE_TIME:PX代表以毫秒为单位设置key的过期时间,目的是保证第二点,不会死锁。
  5. expireTime:自动释放时间。

释放锁,直接删除key行得通吗?

public static void wrongRelsaseLock(Jedis jedis, String lockKey) {
	jedis.del(lockKey);
}

没有对客户端requestId进行判断,可能会释放其他客户端持有的锁。
先判断后删除呢?

public static void wrongRelsaseLock(Jedis jedis, String lockKey, String requestId) {
    if (requestId.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
}

如果在判断的时候属于这个客户端,在释放锁的时候,这把锁已经不属于当前客户端(例如已过期或被其他客户端取得),就会出现释放其他客户端锁的问题。
所以要先判断是不是自己加的锁,才能进行释放。为了保证原子性,需要将判断客户端是否相等和删除key操作放在Lua脚本中执行。

public static void wrongRelsaseLock(Jedis jedis, String lockKey, String requestId) {
    String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
    if (RELEASE_SUCCESS.equals(jedis.get(result)) {
        jedis.del(lockKey);
    }
}
1.2.1.5 Pipeline

Redis在执行命令的时候是单线程的,也就是说在上一个命令请求执行结果相应后,下一个命令才会开始处理。
但是这样的操作非常浪费Redis的性能,因为在大量请求数据时,交互的时间消耗就已经非常惊人,远低于Redis的性能。
Pipeline解决了这个问题,它将一组命令组装在一起发送给Redis服务端执行,然后一次性获得返回结果,实际上,它是通过一个队列将所有的命令都缓存起来,然后把多个命令在一次连接中发送给服务端。
要实现Pipeline既要服务端支持也要客户端支持。对于服务端来说,需要能够处理客户端通过一个TCP连接发来的多个命令,并且逐个执行命令,一起返回。

public static void main(String[] args) {
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    Pipeline pipelined = jedis.pipelined();
    long t1 = System.currentTimeMillis();
    for (int i=0; i < 1000000; i++) {
        pipelined.set("batch"+i,""+i);
    }
    pipelined.syncAndReturnAll();
}
Jedis jedis = new Jedis("127.0.0.1", 6379);
	Set<String> keys = jedis.keys("batch*");
	List<Object> result = new ArrayList();
	Pipeline pipelined = jedis.pipelined();
	long t1 = System.currentTimeMillis();
	for (String key : keys) {
	    pipelined.get(key);
	}
	result = pipelined.syncAndReturnAll();
	for (Object src : result) {
	    System.out.println(src);
	}
}

对于客户端来说,要把多个命令缓存起来,达到一定的条件就发送出去,最后才处理Redis的应答。
Jedis-Pipeline的client-buffer限制:8192bytes,客户端堆积的命令超过8M时,会发送给服务端。

public RedisOutputStream(final OutputStream out) {
	this(out, 8192);
}

Pipeline对命令条数没有限制,但TCP包的大小可能会限制。
Pipeline本身适合于批量写入,但不要强求结果的实时性和成功性的操作。对实时要求返回结果的场景,Pipeline就不合适。

1.2.2 Lettuce

1.2.2.1 Lettuce的特点

与Jedis相比,Lettuce没有线程不安全的缺点。它是一个可伸缩的线程安全Redis客户端,支持同步、异步和相应模式。多个线程可以共享一个连接实例,不会发生并发问题。
连接池示例:

public static void main(String[] args) throws Exception {
    RedisClient client = RedisClient.create("redis://localhost:6379");
    // 使用连接池
    GenericObjectPool<StatefulRedisConnection<String, String>> pool = ConnectionPoolSupport
            .createGenericObjectPool(() -> client.connect(), new GenericObjectPoolConfig());

    try (StatefulRedisConnection<String, String> connection = pool.borrowObject()) {
        RedisCommands<String, String> commands = connection.sync();
        commands.set("test", "6379");
    }
    pool.close();
    client.shutdown();
}

异步与同步调用

public static void main(String[] args) {
    RedisClient client = RedisClient.create("redis://192.168.44.181:6379");
    // 线程安全的长连接,连接丢失时会自动重连
    StatefulRedisConnection<String, String> connection = client.connect();
    // 获取异步执行命令api
    RedisAsyncCommands<String, String> commands = connection.async();
    // 获取RedisFuture<T>
    commands.set("key:async","async-value");
    RedisFuture<String> future = commands.get("key:async");
    try {
        String value = future.get(60, TimeUnit.SECONDS);
        System.out.println("------"+value);
    } catch (InterruptedException | ExecutionException | TimeoutException e) {
        e.printStackTrace();
    }

}
public static void main(String[] args) {
    // 创建客户端
    RedisClient client = RedisClient.create("redis://192.168.44.181:6379");
    // 线程安全的长连接,连接丢失时会自动重连
    StatefulRedisConnection<String, String> connection = client.connect();
    // 获取同步执行命令,默认超时时间为 60s
    RedisCommands<String, String> sync = connection.sync();
    // 发送get请求,获取值
    sync.set("key:sync","sync" );
    String value = sync.get("key:sync");
    System.out.println("------"+value);
    //关闭连接
    connection.close();
    //关掉客户端
    client.shutdown();
}

Lettuce基于Netty框架构建,支持Redis的全部高级开发功能,如发布订阅,事务,Lua脚本,Sentinel,集群,Pipeline,支持连接池。
Lettuce是Spring Boot 2.x默认的客户端,替换了Jedis。集成后,只需要直接调用RedisTemplate操作,连接,创建和关闭都无需操心。

1.2.3 Redisson

1.2.3.1 Redisson的本质

Redisson是一个在Redis的基础上实现的Java驻内存数据网络,提供了分布式和可扩展的Java数据结构,比如分布式的Map,List,Queue,Set,无需自己运行一个服务即可实现。

1.2.3.2 Redisson的特点

基于Netty实现,采用非阻塞I/O,性能高,支持异步请求。
支持连接池,pipeline,LUA Scripting,Redis Sentinel,Redis Cluster
不支持事务,建议采用Lua脚本代替事务
主从,哨兵,集群都支持,Spring可以通过配置使用RedissonClient

1.2.3.3 实现分布式锁

在Redisson提供了更加简单的分布式锁的实现。

public static void main() throws InterruptedException{
	RLock rLock = redissonClinet.getLock("lockName");
	//最多等待100秒,上锁10秒后自动解锁。
	if(rLock.try(100, 10, TimeUnit.SCECOND) {
		System.out.println("成功获得锁");
	}
	rLock.unlock();
}

在获得RLock之后,只需要一个tryLock方法,里面有三个参数:

  1. waitTime:获取锁的最大等待时间,超过这个时间不再继续尝试。
  2. leaseTime:如果没有调用unlock,超过了这个时间会自动释放锁。
  3. TimeUnit:释放时间的单位。

Redisson的分布式锁实现原理是加锁的时候,在Redis中写入一个HASh,key是锁名称,field是线程名称,value是1(表示锁的重入次数)
源码:
tryLock()——tryAcquire()——tryAcquireAsync()——tryLocklnnerAsync()
最终也是使用Lua脚本,里面有一个参数,两个参数的值。

占位填充含义实际值
KEY[1]getName()锁的名称(key)lockName
ARGV[1]internalLockLeaseTime锁的释放时间(毫秒)10000
ARGV[2]getLockName(threadId)线程名称
//KEYS[1] 锁名称 lockName
//ARGV[1] key 过期时间 10000ms
//ARGV[2] 线程名称
//锁名称不存在
if(redis.call('exists', KEYS[1]) == 0) then
	//创建一个hash,key=锁名称,field=线程名,value=1
	redis.call('hset', KEYS[1], ARGV[2], 1);
	//设置hash的过期时间
	redis.call('pexpire', KEYS[1], ARGV[1]);
	return nil;
end
//锁名称存在,判断是否为当前线程持有的锁
if(redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
	//如果是。value+1,代表重入次数+1
	redis.call('hincrby', KEYS[1], ARGV[2], 1);
	//重新获得锁,需要重新设置Key过期时间
	redis.call('pexpire', KEYS[1], ARGV[1]);
	return nil;
end
//锁存在,但是不是当前线程持有,返回过期时间(毫秒)
return redis.call('pttl', KEYS[1]);

释放锁:

占位填充含义实际值
KEY[1]getName()锁的名称lockName
KEY[2]getChannelName()频道名称redisson_lock_channel:{lockName}
ARGV[1]LockPubSub.unlockMessage解锁时的消息0
ARGV[2]internalLockLeaseTime锁的释放时间(毫秒)10000
ARGV[3]getLockName(threadId)线程名称
//KEYS[1] 锁名称 lockName
//KEYS[2] 频道名称 redisson_lock_channel:{lockName}|
//ARGV[1] 释放锁的消息 0
//ARGV[2] key 过期时间 10000ms
//ARGV[3] 线程名称
//锁不存在(过期或已被释放)
if(redis.call('exists', KEYS[1]) == 0) then
	//发布锁已经释放的消息
	redis.call('publish', KEYS[2], ARGV[1]);
	return 1;
end
//锁存在,判断是否为当前线程持有的锁
if(redis.call('hexists', KEYS[1], ARGV[3]) == 1) then
	return nil;
end
//锁存在,是当前线程加的锁
//锁重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
//-1后大于0,说明这个线程持有这把锁还有其他的任务需要执行
if(counter > 0) then
	//重新设置锁的过期时间
	redis.call('pexpire', KEYS[1], ARGV[2]);
	return 0;
else
	//等于0,证明是最后一个持有者,可以删除了。
	redis.call('del', KEYS[1]);
	//删除之后发布释放锁的信息
	redis.call('publish', KEYS[1], ARGV[1]);
	return 1;
end
//其他情况返回nil
return nil;

这个是Redisson里面的分布式锁的实现,所以调用的时候非常简单。
在Redisson中,有一个watchdog看门狗,在未设定锁定时长的时候可以自动帮助还未完成的锁增加存在时间,避免事务未完成就将锁释放的情景。
集群模式下,Redisson会自动选择同一个master,避免对多个master加锁,导致重复加锁

Redisson和Jedis定位不同,它不是一个单纯的Redis客户端,而是基于Redis实现的分布式的服务,如果有需要用到一些分布式的数据结构,比如我们还可以基于Redisson的分布式队列实现分布式事务。

2 数据一致性

2.1 缓存使用场景

针对独对血少的高并发场景,可以使用缓存提高查询速度
当我们使用Redis作为缓存时,一般的流程如下

  1. 如果数据在Redis存在,即可直接从Redis拿到数据,不用访问数据库。
  2. 应用新增了数据,之保存到数据库中,这个时候Redis没有这条数据。如果Redis里面没有,先到数据库查询,然后写入到Redis,最后返回给应用。

2.2 一致性问题的定义

因为数据的最后是以数据库为准,如果Redis没有数据,就不存在这个问题。当Redis和数据库都有同一条记录,而这条记录发生变化的时候,就可能出现一致性问题。
一旦被缓存的数据发生变化,就必须既要操作数据库的数据,又要操作Redis的数据,才能让Redis和数据库保持一致,因此产生了两种方案:

  1. 先操作Redis的数据,然后操作数据库的数据。
  2. 先操作数据库的数据,然后操作Redis的数据。

当然,无论选择哪一种,目的都是让这两个操作要么一起成功,要么一起失败。但是Redis的数据和数据库的数据是不可能通过事务达到统一的,因此只能根据相应的场景和需要付出的代价来采用一些措施降低数据不一致出现的可能性,平衡性能和一致性。

2.3 方案选择

2.3.1 Redis的操作

当存储的数据发生变化,Redis的数据也要更新的时候,有两种选择:

  1. 直接过呢更新Redis数据,调用set命令
  2. 直接删除Redis数据,下次查询时重新写入

这两种方案的选择前提,是更新缓存的代价,如果在更新缓存之前,需要进行其他操作,如表查询,接口调用,数据计算之后才能得到的话,就比较建议直接删除缓存,这种方案更加简单,而且避免了数据库的数据和缓存数据不一致的场景。
所以,无论是更新操作和删除操作,只要数据有变动,就直接删除缓存。
当确定缓存的数据操作方案之后,需要确定的就是数据库和缓存的执行顺序问题。

2.3.2 先更新数据库,再删除缓存

成功的执行顺序为:更新数据库,成功。删除缓存,成功。
异常的执行顺序为:

  1. 更新数据库失败,程序捕捉异常,终止程序。
  2. 更新数据库成功,删除缓存失败,数据库数据为新数据,缓存时旧数据,数据有一致性问题。

第一种方案,尝试重试机制。
比如,如果删除缓存失败,可以对这个异常进行捕获,把需要删除的Key发送到消息队列中,用一个消费者接收消息,将这个key再删除一次。
这种方式有个缺点,会对业务代码造成入侵。
第二种方案,异步更新缓存
因为更新数据库时会往binlog写入日志,所以可以通过一个服务来监听binlog的变化(比如阿里的canal),然后在客户端完成删除key的操作。如果删除失败,再发送到消息队列。
总之,对于后删除缓存失败的情况,唯一要做的就是反复尝试删除,直到成功,无论是重试还是异步删除,都是最终一致性的思想。

2.3.3 先删除缓存,再更新数据库

成功的执行顺序为:删除缓存,成功。更新数据库,成功。
异常的执行顺序为:

  1. 删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。
  2. 删除缓存成功,更新数据库失败,因为以数据库的数据为准,所以不存在数据不一致的情况。

这样看起来好像没有问题,但实际上,在有程序出现并发状况的时候,会出现问题:

  1. 线程A需要更新数据,首先删除了Redis缓存。
  2. 线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回
  3. 线程A更新数据库
  4. 数据库与缓存数值不一致

这个时候,Redis的缓存数值为旧值,数据库的数据是新值,出现数据一致性问题。
由于是并发产生的问题,强制串行可以解决这个问题,但是这显然不能实际应用,所以可以采用一种策略:延时双删
延时双删,再写入数据后,再删除一次缓存。
执行过程:删除缓存——更新数据库——休眠——再次删除缓存。
这样就可以避免数据一致性问题。

3 高并发问题

在Redis存储的所有数据,有一部分会被频繁的访问。有两种情况可能导致热点问题的产生:
第一个,用户集中访问的数据,比如商品的抢购,明星的绯闻发生时的微博
第二个,数据分片时,负载不均衡,超过单一服务器的承受能力
这两种情况可能导致缓存服务的不可用,最终造成压力堆积到数据库。
为了避免这种问题,我们必须要找到这些热点数据。

3.1 热点数据发现

首先,Redis的缓存淘汰机制,能够留下那些热点的Key,不管是LRU和LFU。

3.1.1 客户端

在客户端,可以通过修改Jedis的源码来增加计数功能,但是这样有很多缺点:

  1. 会对客户端的代码造成入侵
  2. 不知道要存多少个key,可能会发生内存泄露
  3. 只能统计当前客户端的热点

3.1.2 代理层

在代理端实现,比如TwemProxy或者Codis,但是不是所有的项目都使用了代理的架构。

3.1.3 服务端

在服务端进行设计,Redis有一个monitor的命令,可以监控到所有的Redis执行的命令。

jedis.monitor(new JedisMonitor() {
	@Override
	public void onCommand(String command){
		System.out.println("#monitor:" + command);
	}
}

FaceBook的开源项目redis-faina就是基于这个原理实现的,它是一个python脚本,可以分析monitor的数据。

redis-cli -p 6379 monitor | head -n 100000 | ./redis-faina.py

这种方法也有一定的问题:

  1. monitor命令在高并发的场景下,会影响性能,所以不适合长时间使用。
  2. 只能拥挤一个Redis节点的热点Key。

3.1.4 机器层面

可以通过对TCP协议进行抓包,在机器层面进行统计,比如说ELK的packetbeat插件

3.2 缓存击穿

3.2.1 什么是缓存击穿

缓存击穿就是Redis的一条热点数据同时过期,数据请求落到数据库上。

3.2.2 缓存击穿的解决方案

  1. 缓存定时预先更新,避免失败。
  2. 缓存永不过期。

3.3 缓存雪崩

3.3.1 什么是缓存雪崩

缓存雪崩就是Redis的大量热点数据同时过期,请求的并发量又很大,导致所有的数据请求都落到数据库。

3.3.2 缓存雪崩的解决方案

  1. 加互斥锁或者使用队列,针对同一个key只允许一个线程到数据库查询。
  2. 缓存定时预先更新,避免同时失败。
  3. 通过加随机数,使key在不同时间过期。
  4. 缓存永不过期。

3.4 缓存穿透

3.4.1 缓存穿透何时发生

当数据在数据库和缓存都不存在,可能是一次条件错误的数据查询,在这种场景,因为数据库中不存在这个数值,所以一定不会在Redis里面写入,那么下一次查询相同key的时候肯定还会重复此过程,那么为了避免这种循环查询数据库中不存在的值,并且每次使用的是相同key的情况,就必须想办法避免到数据库中查询。
为了解决这个问题,可以缓存空数据,或者缓存特殊字符串,比如&&,那么在请求拿到这个特殊的字符串的时候,就知道数据库没有值了,也就没有必要再去数据库查询了。
在这里需要设置一个过期时间,不然的话数据库已经新增了一条记录,请求也无法拿到值。
这个是请求重复查询同一个不存在的值的情况,如果应用每一次查询的不存在的值是不一样的呢?这样的话就算每次都缓存特殊字符串也没用,因为值是不一样的,这样Redis就失去作用了。

3.4.2 经典问题

其实这是一个很通用的问题,问题的关键在于怎么知道请求的key在数据库里面是否存在,尤其是数据量很大的情况下,如何进行快速判断。
比如,如何在大量,无序,不定长,不重复的数据里快速判断一个元素的存在?
为了避免缓存穿透,这些数据不应该被放到数据库里,为了加快检索速度,应该将数据放到内存里。但是将数据直接放进内存里的话,大量的数据会占用大量的内存空间,这对于普通的服务器是完全无法承受的,所以必须要有一种节省空间的数据结构,避免直接存储数值。
这种数据结构被称为位图(BitMap),它是一个有序数组,只有两个值,0和1,0代表存在,1代表不存在,再用一个映射方法,把元素映射到一个对应下标位置上。
对于这个映射方法有几个要求:

  1. 要查找的数值长度是不固定的,但是又希望能得到一个固定长度的输出。
  2. 转换成下标的时候,元素的映射分布应该是均匀的,不能集中到某一部分,否则就很难判断出到底那些元素是存在的。

很明显,哈希函数就能够满足要求。

3.4.3 哈希碰撞

这个时候,Tom和Mic经过计算得到的哈希值是一样的,那么在经过位运算得到的下标肯定一样,这种情况被称之为哈希冲突或哈希碰撞,想要解决这个问题,有以下几种方案:

  1. 扩大数组的长度或者说位图容量,因为函数是分布均匀的,所以位图容量越大,在同一个位置发生哈希碰撞的概率就越小。
  2. 两个元素经过一次哈希之后,得到相同下标的几率比较高,既然如此,就进行多次哈希计算,这样得到相同下标的概率就小得多了。

3.4.4 布隆过滤器

3.4.4.1 原理

布隆过滤器的本质就是一个位数组,加上几个哈希函数。
假设集合里面有3个元素,要把它存到布隆过滤器里,要对a,b,c三个元素都进行3次计算,它们的三次结果都为1。
元素已经存进去之后,现在我要来判断一个元素在这个容器里是否存在,就要使用同样的三个函数进行计算。
假设出现第四个元素d,使用同样的三个函数进行计算,三次计算结果都为1,这种情况下,能不能确定d元素一定在这个容器里面呢?实际上无法判断,因为a,b,c三个元素都是在将自己存进去的时候才把那个对应的位置设成1的,所以就算d元素之前没存进去,也会得到3个1,判断结果为存在.
这个就是布隆过滤器的一个很重要的特性,因为哈希碰撞无法避免,所以它会存在一定的误判率。将本不存在布隆过滤器中的元素误判为存在的情况,被称为假阳性。
假设出现第五个元素e,使用同样的三个函数进行计算,第一次,第二次都为1,第三次此为0,那么这个元素一定不存在,因为如果存在,存入的时候这三个位置就都会被设为1,不可能有0.

3.4.4.2 特点

从容器的角度说

  1. 如果布隆过滤器判断元素在集合中存在,那它不一定存在。
  2. 如果布隆过滤器判断元素在集合中不存在,那它一定不存在。
    从元素的角度说
  3. 如果元素实际存在,在布隆过滤器中一定判断存在。
  4. 如果元素实际不存在,在布隆过滤器中可能判断存在

因此,只要利用第二个特性,就可以判断出一个元素是否真的存在。

3.4.4.3 Guava实现的布隆过滤器

谷歌的Guava提供了一个现成的布隆过滤器

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>21.0</version>
</dependency>

实现示例

BloomFilter<String> bf = 
	BloomFilter.create(Funnel.stringFunnel(Charsets.UTF_8), 100(预计长度));

布隆过滤器提供的元素放置方法为put(),判断方法为mightContain()。
布隆过滤器的误判率默认为0.03,也可以在创建时指定

public static <T> BloomFilter<T> create(Funnel<? super T> funnel, 
	long expectedInsertions) {
	return create(funnel, expectedInsertions, 0.03D
}

位图的容量是根据元素个数和误判率算出来的

long numBits = optimalNumOfBits(expectedInsertions, fpp);

根据位数组的大小,我们进一步计算出了哈希函数的个数

int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);

存储100万个元素只占用了0.87M的内存,生成了5个哈希函数。

3.4.4.4 布隆过滤器在项目中的使用

布隆过滤器的工作位置。
因为要判断数据库的值是否存在,所以第一步是加载数据库所有的数据。在去Redis查询之前,现在布隆过滤器中查询,如果布隆过滤器判断不存在,那数据库肯定没有,也就无需查询,反之则执行查询。

3.4.4.5 布隆过滤器的不足和变种

如果数据库删除了,布隆过滤器也应该被删除。但是布隆过滤器里面没有提供删除的方法。实际上,因为存在哈希碰撞的问题,一旦删除数据,因为存在碰撞位置,其他数据的存在判断就会出现错误,所以元素只能存入,不能删除。
假如要实现布隆过滤器的删除,可以尝试HashMap的链式地址法,在每一个位置上增加一个计数器,每命中一次就增加一次计数,每删除一个就减少一次计数,这样就算删除了有哈希碰撞的元素也不会导致判断失误,这种通过计数实现的布隆过滤器就叫Counting Bloom Filter

<dependency>
	<groupId>com.baqend</groupId>
	<artifactId>bloom-filter</artifactId>
	<version>1.0.7</version>
</dependency>

示例

public static void main(String[] args) {
    CountingBloomFilter<String> cbf = new FilterBuilder(1000,
            0.01).buildCountingBloomFilter();
    cbf.add("heihei");
    cbf.add("zhangzhang");
    cbf.add("linlin");

    cbf.remove("linlin");

    System.out.println(cbf.contains("heihei")); //true
    System.out.println(cbf.contains("zhangzhang")); //true
    System.out.println(cbf.contains("linlin")); //false
}
3.4.4.6 布隆过滤器的其他应用
  1. 在爬虫爬数据的时候,判断这个url是否被爬过
  2. 过滤垃圾邮件
  3. 查询某个商品是否存在
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值