- Redis客户端
- 数据一致性
- 高并发问题
Redis客户端
客户端通信原理
客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。 客户端和服务器发送的命令或数据一律以 \r\n (CRLF 回车+换行)结尾。
客户端跟 Redis 之间 使用一种特殊的编码格式(在 AOF 文件里面我们看到了),叫 做 Redis Serialization Protocol (Redis 序列化协议)。特点:容易实现、解析快、可读 性强。客户端发给服务端的消息需要经过编码,服务端收到之后会按约定进行解码,反之亦然。
官方推荐的客户端
客户端 | 描述 |
---|---|
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://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 的依赖实现。
数据一致性
缓存使用场景
针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。
一致性问题的定义
当数据变化的时候, 现在我们有两种选择:
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)缓存永不过期
缓存穿透
缓存穿透何时发生
我们有没有什么办法避免应用到数据库查询呢?
(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 个哈希函数。
隆过滤器在项目中的使用
布隆过滤器的其他应用场景
比如爬数据的爬虫,爬过的 url 我们不需要重复爬,那么在几十亿的 url 里面,怎么 判断一个 url 是不是已经爬过了?
还有我们的邮箱服务器,发送垃圾邮件的账号我们把它们叫做 spamer,在这么多的邮箱账号里面,怎么判断一个账号是不是 spamer 等等一些场景,我们都可以用到布隆 过滤器。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 951488791@qq.com