Redis实战篇

  • Redis客户端
  • 数据一致性
  • 高并发问题

Redis客户端

客户端通信原理

客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。 客户端和服务器发送的命令或数据一律以 \r\n (CRLF 回车+换行)结尾。

客户端跟 Redis 之间 使用一种特殊的编码格式(在 AOF 文件里面我们看到了),叫 做 Redis Serialization Protocol (Redis 序列化协议)。特点:容易实现、解析快、可读 性强。客户端发给服务端的消息需要经过编码,服务端收到之后会按约定进行解码,反之亦然。

https://redis.io/clients#java

官方推荐的客户端

客户端 描述
Jedis A blazingly small and sane redis java client
lettuce Advanced Redis client for thread-safe sync, async, and reactive usage. Supports Cluster, Sentinel, Pipelining, and codecs.
Redisson distributed and scalable Java data structures on top of Redis server

Jedis

Jedis 是我们最熟悉和最常用的客户端。轻量,简洁,便于集成和改造。

Jedis 多个线程使用一个连接的时候线程不安全。可以使用连接池,为每个请求创建 不同的连接,基于 Apache common pool 实现。跟数据库一样,可以设置最大连接数 等参数。

Jedis 有 4 种工作模式:单节点、分片、哨兵、集群。

3 种请求模式:Client、Pipeline、事务。

Jedis 实现分布式锁

原文地址:https://redis.io/topics/distlock

中文地址:http://redis.cn/topics/distlock.html

分布式锁的基本特性或者要求:

1、互斥性:只有一个客户端能够持有锁。

2、不会产生死锁:即使持有锁的客户端崩溃,也能保证后续其他客户端可以获 取锁。

3、只有持有这把锁的客户端才能解锁。

加锁
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
    // set 支持多个参数 NX(not exist) XX(exist) EX(seconds) PX(million seconds)
    String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); 
    if (LOCK_SUCCESS.equals(result)) {
            return true;
    }
    return false;
}
解锁
public static boolean releaseDistributedLock(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(result)) {
        return true; }
    return false; 
}

Luttece

与 Jedis 相比,Lettuce 则完全克服了其线程不安全的缺点:Lettuce 是一个可伸缩 的线程安全的 Redis 客户端,支持同步、异步和响应式模式(Reactive)。多个线程可 以共享一个连接实例,而不必担心多线程并发问题。

它基于 Netty 框架构建,支持 Redis 的高级功能,如 Pipeline、发布订阅,事务、 Sentinel,集群,支持连接池。

Lettuce 是 Spring Boot 2.x 默认的客户端,替换了 Jedis。集成之后我们不需要单 独使用它,直接调用 Spring 的 RedisTemplate 操作,连接和创建和关闭也不需要我们 操心。

Redisson

https://redisson.org/

https://github.com/redisson/redisson/wiki/目录

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-MemoryData Grid),提供了分布式和可扩展的 Java 数据结构。

特点

基于 Netty 实现,采用非阻塞 IO,性能高
支持异步请求
支持连接池、pipeline、LUA Scripting、Redis Sentinel、Redis Cluster 不支持事务,官方建议以 LUA Scripting 代替事务

主从、哨兵、集群都支持。Spring 也可以配置和注入 RedissonClient

分布式锁

Redisson 的分布式锁是怎么实现的呢?

在加锁的时候,在 Redis 写入了一个 HASH,key 是锁名称,field 是线程名称,value 是 1(表示锁的重入次数)。

tryLock()——tryAcquire()——tryAcquireAsync()——tryLockInnerAsync()

最终也是调用了一段 Lua 脚本。里面有一个参数,两个参数的值。

// KEYS[1] 锁名称 updateAccount // 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]);

释放锁,源码:

unlock——unlockInnerAsync

// KEYS[1] 锁的名称 updateAccount
// KEYS[2] 频道名称 redisson_lock__channel:{updateAccount} 

// ARGV[1] 释放锁的消息 0
// ARGV[2] 锁释放时间 10000
// 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]) == 0) 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
// -1 之后等于 0,现在可以删除锁了 redis.call('del', KEYS[1]);
// 删除之后发布释放锁的消息 redis.call('publish', KEYS[2], ARGV[1]); return 1;
end; 
// 其他情况返回 nil return nil;

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

数据一致性

缓存使用场景

针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。

image-20200227212914120

一致性问题的定义

当数据变化的时候, 现在我们有两种选择:

1、先操作 Redis 的数据再操作数据库的数据

2、先操作数据库的数据再操作 Redis 的数据

方案的选择

Redis:删除还是更新?

这两种方案怎么选择呢?这里我们主要考虑更新缓存的代价。更新缓存之前,是不是要经过其他表的查询、接口调用、计算才能得到最新的数据, 而不是直接从数据库拿到的值。如果是的话,建议直接删除缓存,这种方案更加简单, 而且避免了数据库的数据和缓存不一致的情况。在一般情况下,我们也推荐使用删除的 方案。

这一点明确之后,现在我们就剩一个问题:

1、到底是先更新数据库,再删除缓存

2、还是先删除缓存,再更新数据库

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

异常情况:

1、更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一

致的情况。

重试的机制。

如果删除缓存失败,我们捕获这个异常,把需要删除的 key 发送到消息队列。 让后自己创建一个消费者消费,尝试再次删除这个 key。

异步更新缓存

因为更新数据库时会往 binlog 写入日志,所以我们可以通过一个服务来监听 binlog 的变化(比如阿里的 canal),然后在客户端完成删除 key 的操作。如果删除失败的话, 再发送到消息队列。

总之,对于后删除缓存失败的情况,我们的做法是不断地重试删除,直到成功。

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

异常情况:

1、删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

2、删除缓存成功,更新数据库失败。 因为以数据库的数据为准,所以不存在数据不一致的情况。

看起来好像没问题,但是如果有程序并发操作的情况下:

1)线程 A 需要更新数据,首先删除了 Redis 缓存

2)线程 B 查询数据,发现缓存不存在,到数据库查询旧值,写入 Redis,返回

3)线程 A 更新了数据库

这个时候,Redis 是旧的值,数据库是新的值,发生了数据不一致的情况。

所以我们有一种延时双删的策略,在写入数据之后,再删除一次缓存。

1)删除缓存

2)更新数据库

3)休眠 500ms(这个时间,依据读取数据的耗时而定)

4)再次删除缓存

高并发问题

在 Redis 存储的所有数据中,有一部分是被频繁访问的。

有两种情况可能会导致热点问题的产生

一个是用户集中访问的数据,比如抢购的商品,明星结婚和明星出轨的 微博。

还有一种就是在数据进行分片的情况下,负载不均衡,超过了单个服务器的承受 能力。热点问题可能引起缓存服务的不可用,最终造成压力堆积到数据库。

出于存储和流量优化的角度,我们必须要找到这些热点数据。

热点数据发现

除了自动的缓存淘汰机制之外,怎么找出那些访问频率高的 key 呢?或者说,我们 可以在哪里记录 key 被访问的情况呢

客户端

第一个当然是在客户端了,比如我们可不可以在所有调用了 get、set 方法的地方,

加上 key 的计数。但是这样的话,每一个地方都要修改,重复的代码也多。

但是这种方式有几个问题:
1、不知道要存多少个 key,可能会发生内存泄露的问题。

2、会对客户端的代码造成入侵。

3、只能统计当前客户端的热点 key。

代理层

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

服务端

第三种就是在服务端统计,Redis 有一个 monitor 的命令,可以监控到所有 Redis 执行的命令。

Facebook 的 开 源 项 目 redis-faina (https://github.com/facebookarchive/redis-faina.git)就是基于这个原理实现的。 它是一个 python 脚本,可以分析 monitor 的数据。

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

这种方法也会有两个问题:

1)monitor 命令在高并发的场景下,会影响性能,所以 不适合长时间使用。

2)只能统计一个 Redis 节点的热点 key。

机器层面

还有一种方法就是机器层面的,通过对 TCP 协议进行抓包,也有一些开源的方案, 比如 ELK 的 packetbeat 插件。

当我们发现了热点 key 之后,我们来看下热点数据在高并发的场景下可能会出现的 问题,以及怎么去解决。

缓存雪崩

什么是缓存雪崩?

缓存雪崩就是 Redis 的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候 Redis 请求的并发量又很大,就会导致所有的请求落到数据库

缓存雪崩的解决方案

1)加互斥锁或者使用队列,针对同一个 key 只允许一个线程到数据库查询

2)缓存定时预先更新,避免同时失效

3)通过加随机数,使 key 在不同的时间过期

4)缓存永不过期

缓存穿透

缓存穿透何时发生

image-20200227214450175

我们有没有什么办法避免应用到数据库查询呢?

(1)缓存空数据 (2)缓存特殊字符串,比如&&

(缓存不存在的key 黑名单,对于恶意key)

经典面试题

如何在海量元素中(例如 10 亿无序、不定长、不重复)快速判断一个元素是否存在?

布隆过滤器原理

https://hur.st/bloomfilter/?n=1000000&p=0.03&m=&k=

首先,布隆过滤器的本质就是我们刚才分析的,一个位数组,和若干个哈希函数。

从容器的角度来说:

1、如果布隆过滤器判断元素在集合中存在,不一定存在

2、如果布隆过滤器判断不存在,一定不存在

Guava 的实现

<dependency> 

    <groupId>com.google.guava</groupId> 

    <artifactId>guava</artifactId>

    <version>21.0</version> 

</dependency>

布隆过滤器把误判率默认设置为 0.03,也可以在创建的时候指定。

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

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

隆过滤器在项目中的使用

image-20200227215112288

布隆过滤器的其他应用场景

比如爬数据的爬虫,爬过的 url 我们不需要重复爬,那么在几十亿的 url 里面,怎么 判断一个 url 是不是已经爬过了?

还有我们的邮箱服务器,发送垃圾邮件的账号我们把它们叫做 spamer,在这么多的邮箱账号里面,怎么判断一个账号是不是 spamer 等等一些场景,我们都可以用到布隆 过滤器。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 951488791@qq.com

文章标题:Redis实战篇

字数:3.5k

本文作者:zhengyumin

发布时间:2020-02-28, 18:52:43

最后更新:2020-06-26, 21:38:11

原始链接:http://zyumin.github.io/2020/02/28/Redis%E5%AE%9E%E6%88%98%E7%AF%87/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。