解读 Redis 在高并发场景中的使用与最佳实践:从缓存机制到高可用集群

Redis 现在用的地方非常多,特别是在高性能、高并发系统中应用非常广泛,消耗资源少,能够实现的功能很多。

数据结构

基本数据类型

  • String:字符串
  • Hash:K->V
  • List:有序列表
  • Set:去列表
  • Sorted Set/ZSet:去重有序列表,按照 score 排序
  • Bitmap(Redis>2.2):位存储,可应用于统计用户信息,活跃,不活跃!登录,未登录!打卡,不打卡!两个状态
  • Geo(Redis>3.2):地理位置
  • HyperLogLog(Redis>2.8):基数统计,可应用于注册 IP 数,每日访问 IP 数,页面实时UV,在线用户数,共同好友数等,存在 0.81% 标准错误的近似值
  • Stream(Redis>5.0):流,消息队列和实时数据处理

String底层数据结构

String通过SDS数据结构实现

len alloc flags buf[]
  • len:字符串的长度
  • alloc:分配的空间长度
  • flags:SDS的类型。包括:sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64
  • buf[]:实际的数据

优点:

  • 查询字符串长度时间复杂度为O(1)
  • 二进制安全。有len记录长度,可以存储\0,为了兼容C语言标准库,SDS还是以\0结尾
  • 不会发生溢出。通过alloc-len可以知道可用的剩余空间大小

ZSet底层数据结构

ZSet通过压缩表或跳表实现,如果元素小于128个,并且每个元素大小小于64个字节,Redis使用压缩表,否则使用跳表

压缩表是怎么实现的?

压缩表分为以下几个部分:

zlbytes zltail zllen entry1 entry2 zlend
  • zlbytes:整个压缩表占用内存的字节数
  • zltail:尾部节点距离起始地址的字节数(偏移量)
  • zllen:包含节点(entrys)的数量
  • entry:节点
  • zlend:结束标识,固定值 0xFF(十进制255)

entry又分为三个部分:

prevlen encoding data
  • prevlen:前节点长度,方便往前遍历
  • encoding:实际数据的类型和长度
  • data:实际的数据

优点:存储连续,省空间

缺点:存在连锁更新问题,当插入或者更新元素时,会引发内存的重新分配

应用场景:元素个数不多(即使发生连锁更新,也不会太影响性能)

跳表是怎么实现的?

跳表是一种多层的有序链表

L2 —————–> 3 ———-> Null

L1 ———-> 2 —> 3 —> 4 —> Null

L0 —> 1 —> 2 —> 3 —> 4 —> Null

L0 L1 L2 是头结点。L0层有1,2,3,4;L1层有2,3,4;L2层有3

如果现在要查询节点4,链表需要逐个遍历,查4次,时间复杂度为O(n)。跳表只需要2次,从L2层查到节点3,然后遍历到节点4,时间复杂度为O( logN)

跳表是如何时间多层级的?

源码位置:https://github.com/redis/redis/blob/ad950e4c32d9ebff932f157cfad135bde4fadce4/src/server.h#L1008

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct zskiplistNode {
// 对象的元素值
sds ele;
// 元素权重值
double score;
// 后向指针
struct zskiplistNode *backward;
struct zskiplistLevel {
// 前向指针
struct zskiplistNode *forward;
// 跨度
unsigned long span;
} level[];
} zskiplistNode;

跳表的层数?

[0-1]之间生成随机数,小于0.25就增加一层,最大层数为64,层数越高的概率越低

为什么使用跳表,而不用B+树?

  1. 数据结构简单,在增加删除的时候,跳表只需要改前后指针,而B+树为了保持平衡,可能会引发子树的调整
  2. 范围查询时遍历快,跳表只需要找到最小值,对第一层进行遍历即可
  3. 占用空间小,平衡树有两个指针(左和右),跳表包含的指针数更少

非阻塞的 IO 多路复用机制

非阻塞的 IO 是即使客户端发送数据不足或读取缓慢,Redis 的线程也不会被阻塞,而是继续处理其他 I/O 事件。

在多路复用机制之前我们需要了解 select poll epoll 三种 I/O 多路复用的实现方式

  • select:逐个遍历所有描述符,单个调用最多监控 1024 个文件描述符,效率低下
  • poll:取消了文件描述符数量的限制
  • epoll:可以监听多个文件描述符,发生事件时,会回调FD绑定的事件处理器进行处理相关命令操作
特性 select poll epoll
文件描述符限制 1024(可扩展) 无限制 无限制
性能 线性下降 线性下降 与活动连接数成正比
内存开销 用户空间和内核频繁拷贝 用户空间和内核频繁拷贝 文件描述符存储在内核空间
事件机制 轮询(全量扫描) 轮询(全量扫描) 事件驱动,仅通知就绪的事件
跨平台性 跨平台支持广泛 跨平台支持广泛 仅支持 Linux
适用场景 小规模并发 中小规模并发 高并发和长连接(如网络服务)

Redis 使用的是 epoll

过期策略

定期删除+惰性删除,定期删除,Redis 是每隔 100ms 随机抽取一些 key 来检查和删除, 随机抽查20个,如果已过期的key超过5个(>25%),则重复以上操作,但循环时间不会超过25ms。 惰性删除,get 的时候判断是否过期,过期则删除。

但是还是存在问题,如果定期没检查到,而且一直没有 get 则会有过期 key 堆积在内存里

内存淘汰策略:

  • noeviction(默认): 当内存不足时,直接返回错误,不再接受写入操作
  • allkeys-lru(常用):当内存不足时,在所有键中,移除最近最少使用的 key
  • volatile-lru:当内存不足时,在设置了过期时间的键中,移除最近最少使用的 key
  • allkeys-random:当内存不足时,在所有键中,随机移除 key
  • volatile-random:当内存不足时,在设置了过期时间的键中,随机移除 key
  • volatile-ttl:当内存不足时,在设置了过期时间的键中,优先淘汰剩余时间(TTL)最小的键
  • allkeys-lfu:当内存不足时,在所有键中,移除最少使用的 key
  • volatile-lfu:当内存不足时,在设置了过期时间的键中,移除最少使用的 key

设置内存限制和淘汰策略

1
2
3
4
5
6
# redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru
# 动态修改
CONFIG SET maxmemory 512mb
CONFIG SET maxmemory-policy allkeys-lru

常见场景与推荐策略:

场景 推荐策略 原因
普通缓存系统 allkeys-lru 优先保留最近访问的数据
缓存热点数据 allkeys-lfu 优先保留访问频率较高的数据
只淘汰过期数据 volatile-lru 只清理设置了过期时间的键
数据丢失不可接受 noeviction 保证数据安全,避免数据被删除
简单随机淘汰 allkeys-random 性能简单,但淘汰策略不具针对性

实现 LRU 算法(leetcode#146)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class LRUCache extends LinkedHashMap<Integer, Integer>{
private int capacity;

public LRUCache(int capacity) {
super(capacity, 0.75F, true);
this.capacity = capacity;
}

public int get(int key) {
return super.getOrDefault(key, -1);
}

public void put(int key, int value) {
super.put(key, value);
}

@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}