Redis 是一个使用 C 语言编写,高性能 K-V 非关系数据库(NoSQL)。它支持存储多种数据类型,如:string,list,set,sorted set,hash 等。
Redis 读写性能优异,可达10万级 QPS。在 Web 在应用程序开发的早期阶段,网站本身的访问量不是很高,直接使用关系数据库可以处理大多数场景。然而,随着互联网时代的兴起,人们对网站访问速度的要求越来越高,直接使用关系数据库的方案在性能上存在瓶颈。
主流应用架构的解决方案是在客户端和数据存储层之间添加缓存层:
如果用户第一次访问数据库中的一些数据,我们将将数据存储到缓存中,除了将数据正常返回给用户。当用户下次访问这些数据时,可以直接从缓存中获取。从缓存中获取数据比直接查询数据库快得多,因为操作缓存注意Java高级营,易学IT】通过缓存可以显著提高性能,相当于直接操作内存。不仅如此,使用缓存还可以实现熔断机制。当我们的数据库崩溃时,我们可以直接从缓存中返回,不再要求进行数据库查询,并在数据库修复后恢复呼叫。
分布式缓存中间件有两种:Redis 与 Memcached。
Memcached 也是一种 K-V 非关系数据库的优点是使用方便简单,缺点是不支持数据的持久存储,不支持主同步和分片功能。
相比于 Memcached,Redis 支持更多的数据存储类型,支持 RDB,AOF 支持两种持久存储机制 Cluster 可实现主从同步和分片功能。
Redis 在整体上比 Memcached 强大,这也是 Redis 许多企业作为分布式缓存的首选。
当我们打开它时 Redis 客户端与服务端连接,客户端与服务端通过套接 Socket 处理他们之间的读写请求。
譬如,我们在 Redis 输入一个客户端 get 命令:
127.0.0.1:6379> get name
那么对于 Redis 服务端发生了什么?
首先,服务端必须首先监控客户端的请求(listen),然后,当客户到达时,与他建立联系(accept),然后服务端需要从套接字开始 Socket 中读客户端请求(recv),分析请求(parse),这里分析的请求类型是 “get”,key 为 “name”,然后再根据 key 获取对应的 value ,最后,将值返回给客户端,即向 Socket 写入数据(send)。
Socket 当 Redis 通过 recv 或 send 在向客户端读写数据时,如果数据尚未到达,则 Redis 主线程将始终处于堵塞状态。
Redis 自然不会使用阻塞 I/O,它将 Socket 设置为非阻塞模式(Non_Blocking ),在非阻塞 I/O 在模式下,阅读和写作方法不会被阻塞,而是你可以读多少,你可以写多少。当客户端不发送数据时,主线程不会等待,而是直接返回并做其他事情。
除了将 Socket 设置为非阻塞 I/O,Redis 还使用了 I/O 使用多路复用机制 Redis 可以在单线程模型下高效处理多个 I/O 流:
Redis 基于 Reactor 该模型开发了自己的文件事件处理器(File Event Handler)。由套接字组成,I/O 由多路复用程序、文件事件分配器和事件处理器组成。
I/O 多路复用程序(epoll / kqueue / select…,根据操作系统的不同,使用不同的函数,Linux 使用的是 epoll,Mac OS 则是 kqueue)会监督多套接字,每当一套接字准备执行应答、读写、关闭等操作时,就会产生相应的文件事件,这些事件会存储在事件队列中,并将队列传输到文件事件分发器。根据这些事件对应的类型,文件事件分配器将调用不同的事件处理器来执行相应的事件回调函数。
I/O 多路复用机制 Redis 不需要一直轮询是否有要求,以免浪费资源。
回到我们的主要问题上——Redis 为什么这么快?
总结原因如下:
- 基于内存操作,性能高
- 使用了大量高效的数据结构
- 虽然是单线程模型,但使用非阻塞 I/O 与 I/O 多路复用机制大大提高 Redis 的读写性能
- 因为 Redis 是单线程模型,避免了不必要的上下文切换和多线程竞争
我在上面提到的 Redis 单线程模型是指文件事件队列的生产者-消费者模型。使用单线程模型的主要原因是 Redis 性能瓶颈不在于 CPU,而是内存和网络带宽。 CPU 不是影响 Redis 速度的主要原因是易于编写的单线程模型。
从 Redis 4.0 当版本开始时,它被引入 Lazy Free 机制。当用户使用 DEL 命令删除较大 Key 时间,或使用 FLUSHDB 或 FLUSHALL 命令删除包含大量 Key 在数据库中,服务器很可能被堵塞。Redis 4.0 添加了 UNLINK 命令,这是 DEL 命令的异步版本,Redis 删除操作将使用额外的后台线程,以避免服务器造成的阻塞:
127.0.0.1:6379> unlink name
(integer) 1
此外,Redis 4.0 还可以在 FLUSHDB 和 FLUSHALL 命令后添加 ASYNC 该选项的删除操作也将在后台线程中执行:
127.0.0.1:6379> flushdb async
OK
127.0.0.1:6379> flushall async
OK
如果说,从 Redis 4.0 在版本开始时,多线程机制被初步引入 Redis 6.0 在版本开始时,多线程正式引入。
Redis 6.0 为了改进网络数据的读写和协议分析,增加了多线程 IO 性能,但执行命令仍由单线程序执行。
Redis 6.0 默认禁用多线程。如果需要打开,则需要修改 redis.conf 配置文件:
设置 io-thread-do-reads 配置项为 yes,表示启用多线程
io-thread-do-reads yes
然后通过 io-threads 设置子线程的数量
io-threads 3
官方建议:4 建议设置核机 2 或 3 个线程,8 核的机器建议设置 6 线程数必须小于机器核数。
Redis 五种常用数据类型为:
- String
- Hash
- List
- Set
- Zset
String 是 Redis 最基本的数据类型通常用于存储最简单的键值。可存储的值类型为字符串、整数或浮点,支持整数和浮点的自增或自减操作:
127.0.0.1:6379> set test:count 1
OK
127.0.0.1:6379> get test:count
“1”
127.0.0.1:6379> incr test:count
(integer) 2
127.0.0.1:6379> decr test:count
(integer) 1
Hash 通常用来存储结构化的数据,譬如用于存储一个对象:
127.0.0.1:6379> hset test:user id 1
(integer) 1
127.0.0.1:6379> hset test:user username zhangsan
(integer) 1
127.0.0.1:6379> hget test:user id
“1”
127.0.0.1:6379> hget test:user username
“zhangsan”
Redis 中的 List 类似于 Java 中的 Deque,它可以从两端压入(lpush,rpush)或弹出(lpop,rpop)数据结构。我们可以使用它 List 存储列表类型的数据,如粉丝列表、文章评论列表等:
127.0.0.1:6379> lpush test:ids 101 102 103
(integer) 3
127.0.0.1:6379> llen test:ids
(integer 3
127.0.0.1:6379> lrange test:ids 0 2
-
“103”
-
“102”
-
“101”
127.0.0.1:6379> lindex test:ids 0
“103”
127.0.0.1:6379> rpop test:ids
“101”
127.0.0.1:6379> lpop test:ids
“103”
Set 为无序集合,我们可以使用 spop 命令从集合中弹出一个随机的元素,使用 smembers 来查看集合中所有的元素:
127.0.0.1:6379> sadd test:teachers aaa bbb ccc ddd eee
(integer) 5
127.0.0.1:6379> scard test:teachers
(integer) 5
127.0.0.1:6379> spop test:teachers
“eee”
127.0.0.1:6379> smembers test:teachers
-
“bbb”
-
“ddd”
-
“ccc”
-
“aaa”
Zset 即有序集合,它通过分数(score)为集合中的成员进行从小到大的排序,使用的典型场景为排行榜:
127.0.0.1:6379> zadd test:students 10 aaa 20 bbb 30 ccc 40 ddd 50 eee
(integer) 5
127.0.0.1:6379> zcard test:students
(integer) 5
127.0.0.1:6379> zscore test:students ccc
“30”
127.0.0.1:6379> zrank test:students ccc
(integer) 2
127.0.0.1:6379> zrange test:students 0 2
-
“aaa”
-
“bbb”
-
“ccc”
除了以上五种常用的基本数据类型,Redis 还有一些功能强大的高级数据类型,譬如:
- HyperLogLog
- Geo
HyperLogLog 采用一种基数算法,用于完成独立总数的统计。譬如,我们有一个需求,要记录网站每天的 UV 数据(Unique Visitor)。当然,我们可以使用 Set,它能满足去重的需求,不过如果该网站的访问量非常巨大,那么使用 Set 来统计便很浪费空间了。我们可以使用 HyperLogLog 这种数据结构,它的优点是无论有多少统计数据,都只会占用 12 KB 的内存空间。不过 HyperLogLog 只是用来做基数统计的,并不会保存元数据,并且它采用的是一种不精确的统计算法,标准误差为 0.81%。即便是这样,HyperLogLog 也可以满足绝大部分的统计需求。
127.0.0.1:6379> pfadd uv user1
(integer) 1
127.0.0.1:6379> pfadd uv user2
(integer) 1
127.0.0.1:6379> pfadd uv user3 user4 user5
(integer) 1
127.0.0.1:6379> pfcount uv
(integer) 5
Geo 则是一种用于存储地理位置信息的数据结构,支持将一个或多个经度,纬度,位置名称添加到指定的 Key 中,感兴趣的朋友可以自行了解一下~
Redis 中,有序集合 Zset,其底层实现为 跳表 + 散列表。
为什么 Redis 要使用跳表来实现有序集合而不是红黑树呢?
Redis 中 Zset 主要支持以下几种操作:
- 插入一个数据
- 删除一个数据
- 查找一个数据
- 按照区间查找数据(比如查找值在 [100,356] 之间的数据)
- 迭代输出有序序列
其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,并且时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
对于按照区间查找数据,跳表可以做到 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了,这样做非常高效。
当然,Redis 之所以使用跳表来实现有序集合,还有其他原因,比如,跳表比起红黑树的实现简直是容易多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变上层索引构建策略,有效平衡执行效率和内存消耗。
首先,要说明的一点是,我们的 Redis 正在给线上的业务提供服务。
如果在数据规模不大的情况下,我们可以使用 keys pattern 命令来返回当前的 Key 中所有符合 pattern 定义的 Key,譬如要查询所有以 “K” 开头的 Key,我们可以输入这样的命令:
127.0.0.1:6379[10]> keys K*
但是,如果题目中加上“海量数据”这一前提,使用 keys 命令就有一定的隐患了。
在 Redis 拥有数百万甚至是几千万以上的 Key 时,执行 keys 命令的速度会非常慢,keys 命令会将所有的 Key 全部遍历一遍,其复杂度为 。不仅如此,在数据量极大的情况下,该命令会阻塞 Redis I/O 多路复用的主线程一段时间,如果我们的 Redis 正在为线上的业务提供服务,主线程一旦被阻塞,那么在此期间其他的发送向 Redis 服务端的命令都会被阻塞,从而导致 Redis 出现卡顿,引发响应超时的问题。
取而代之的,我们可以使用 scan 命令:
scan cursor [MATCH pattern] [COUNT count]
scan 命令的时间复杂度同样为 ,不过它需要迭代多次才能返回完整的数据,并且每次返回的数据有可能会产生重复。
cursor 为查询游标,游标从 0 开始,也从 0 结束。每次返回的数据中,都有下一次游标该传入的值,我们通过这个值,再去进行下一轮的迭代。scan 命令并不保证每次执行都会返回某个给定数量的元素,每次迭代返回的元素数量都是不确定的。所以,即便是返回了 0 条数据,只要返回的游标值不为 0,就说明需要继续迭代,只有当返回的游标值为 0 时,才代表迭代完毕。
pattern 为匹配的表达式。
count 指定了每次迭代返回元素的最大数量,默认值为 10。
示例如下:
127.0.0.1:6379[10]> scan 0 match K* count 10
-
“20”
-
- “K1”
-
“K5”
-
“K4”
127.0.0.1:6379[10]> scan 20 match K* count 10
-
“33”
-
- “K3”
-
“K6”
-
“K8”
127.0.0.1:6379[10]> scan 33 match K* count 10
-
“11”
-
- “K9”
-
“K2”
127.0.0.1:6379[10]> scan 11 match K* count 10
-
“0”
-
- “K7”
scan 命令返回的第一条数据便是我们下一次迭代时的游标,当返回 “0” 时,就表示所有匹配 pattern 的 Key 已经全部返回,迭代结束。
scan 命令通过游标分布执行,不会产生线程阻塞,所以非常适合使用于海量数据的生产环境下。在此我向大家推荐一个架构学习交流圈。交流学习指导伪鑫:1253431195(里面有大量的面试题及答案)里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
在 Spring Boot 整合 Redis 时,我们也可以在 Java 代码中调用 Redis 的 scan 命令,这里需要注意的是,因为每一次迭代都有可能产生重复的数据,所以我们应该使用 Set 或 Map 这样的数据结构来去重。
示例代码如下:
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.test.context.ContextConfiguration;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
@SpringBootTest
@ContextConfiguration(classes = RedisScanCommandApplication.class)
public class ScanCommandTest {
@Resource
private RedisTemplate redisTemplate;
@Test
public void testScan {
String pattern = “K*”;
Long limit = 1000L;
Map<String, String> map = scan(redisTemplate, pattern, limit);
System.out.println(map);
}
private Map<String, String> scan(RedisTemplate redisTemplate, String pattern, Long limit) {
return (Map<String, String>) redisTemplate.execute(new RedisCallback {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
Map<String, String> map = new HashMap<>;
Cursor cursor = connection.scan(new ScanOptions
.ScanOptionsBuilder
.match(pattern)
.count(limit)
.build);
while (cursor.hasNext) {
byte bytesKey = cursor.next;
byte bytesValue = connection.get(bytesKey);
String key = String.valueOf(redisTemplate.getKeySerializer.deserialize(bytesKey));
String value = String.valueOf(redisTemplate.getValueSerializer.deserialize(bytesValue));
map.put(key, value);
}
return map;
}
});
}
}
执行单元测试,程序输出的结果如下:
{K1=K-1, K2=K-2, K3=K-3, K4=K-4, K5=K-5, K6=K-6, K7=K-7, K8=K-8, K9=K-9}
互斥性指的是,在任意时刻,只能有一个客户端获取到锁。
安全性指的是,锁只能被持有该锁的客户端删除,不能由其他的客户端删除。
如果持有锁的客户端因为某些原因宕机而未能释放锁,导致其他客户端再也无法获取到锁便产生了死锁的情况。对于实现分布式锁来说,必须有机制来避免死锁的发生。
在 Redis 中,有一个 SETNX key value 的指令,该指令的含义为:Set if not exists。也就是说,如果 Key 不存在,那么使用 SETNX 便可以创建 Key 并赋值成功;反之,如果 Key 存在,就什么也不做。
127.0.0.1:6379[10]> setnx lock Kim
(integer) 1
127.0.0.1:6379[10]> setnx lock Tom
(integer) 0 // 加锁失败
如上面的代码所示,我们第一次加锁成功,第二次加锁则是失败的。而释放锁也很简单,直接使用 DEL 命令删除这个 Key 即可:
127.0.0.1:6379[10]> del lock
(integer) 1
这个方案看似简单完美,不过却存在着一个很大的问题。当客户端 A 拿到了锁以后,发生了某些原因导致宕机而未能释放锁,那么其他的客户端便无法获取到该锁,进而就产生了死锁的情况。
为了解决这一问题,我们需要给锁设定一个过期时间。在 Redis 中,有一个 EXPIRE key seconds 指令,它可以设置 Key 的存活时间,当 Key 过期时,会被自动删除:
127.0.0.1:6379[10]> setnx lock kim
(integer) 1
127.0.0.1:6379[10]> expire lock 10
(integer) 1
这样一来,锁的释放就得到了保证。
不过该方案也是有问题的。SETNX 与 EXPIRE 属于两条指令,不能保证原子性操作。假如,客户端 A 在执行 SETNX 命令后,还没有来得及为锁设置过期时间就发生了宕机,还是会发生死锁的问题。有没有一种方案可以让使这两条命令绑定在一起呢?
答案是:有的。
在 Redis 2.6 版本开始,便扩展了 SET 命令:
SET key value [EX seconds|PX milliseconds] [NX|XX]
该命令中,新增的参数含义如下:
- EX seconds 为设置 Key 的过期时间,其单位为秒
- PX milliseconds 为设置 Key 的过期时间,其单位为毫秒
- NX 的含义为只有当 Key 不存在时,才会对 Key 进行设置
- XX 的含义为只有当 Key 存在时,才会对 Key 进行设置
这样,我们便可以使用一条命令来实现 SETNX 与 EXPIRE 了 :
127.0.0.1:6379[10]> set lock kim ex 10 nx
OK
在 Spring 整合 Jedis 时,我们可以使用这样的代码完成加锁的逻辑:
private boolean lock(Jedis jedis, String key, String value, int expireSeconds) {
String result = jedis.set(key, value, “NX”, “EX”, expireSeconds);
if (result.equals(“OK”)) {
// do sth
return true;
}
return false;
}
那么如何释放锁呢?
大家可能想到的姿势是:
private void unlock(Jedis jedis, String key, String value) {
if (value.equals(jedis.get(key)))
jedis.del(key);
}
不过这段代码很显然是不能保证原子性的。
举个例子:
假设锁的过期时间设置为 10 秒。客户端 A 在执行 get 锁的命令时,锁过期,与此同时,客户端 B 获取到了锁,而客户端 A 又继续执行了 del 命令,释放了客户端 B 刚刚获取到的锁,这便违背了安全性。
针对于这种情况,我们的解决方法是将获取锁与释放锁这两个命令写成 Lua 脚本交给 Redis 来执行。因为 Redis 执行命令是单线程执行的,所以就保证 Lua 脚本的执行是一个原子的操作。
Spring 整合 Jedis 时,我们可以使用这样的代码完成释放锁的逻辑:
private boolean unlock(Jedis jedis, String key, String value) {
String luaScript = “if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end”;
Object result = jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value));
if (result.equals(1L)) {
// do sth
return true;
}
return false;
}
再来思考一个问题:当我们使用上述方案,对锁设置了过期时间为 10 秒。
假如客户端 A 持有锁,正常的业务执行了 10 秒还未执行完毕,锁因为过期就被释放了,这种情况要怎么办?
Redisson 给出的解决方案是使用看门狗线程。
看门狗线程会定时监测锁的失效时间,如果锁快要过期了,而业务还没有执行完毕就会对锁进行“续期”操作,重新设置锁的过期时间。
Redisson SDK 封装了许多易用的功能,包括:
- 可重入锁(ReentrantLock)
- 公平锁(FairLock)
- 联锁(MultiLock)
- 读写锁(ReadWriteLock)
- 红锁(RedLock)
- … …
关于 Redisson 的具体使用,以及大名鼎鼎的红锁 RedLock,这些部分我会在后续的系列文章中进行介绍,这里就先不展开了。