解读 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 | typedef struct zskiplistNode { |
跳表的层数?
[0-1]之间生成随机数,小于0.25就增加一层,最大层数为64,层数越高的概率越低
为什么使用跳表,而不用B+树?
- 数据结构简单,在增加删除的时候,跳表只需要改前后指针,而B+树为了保持平衡,可能会引发子树的调整
- 范围查询时遍历快,跳表只需要找到最小值,对第一层进行遍历即可
- 占用空间小,平衡树有两个指针(左和右),跳表包含的指针数更少
非阻塞的 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 | # redis.conf |
常见场景与推荐策略:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 普通缓存系统 | allkeys-lru | 优先保留最近访问的数据 |
| 缓存热点数据 | allkeys-lfu | 优先保留访问频率较高的数据 |
| 只淘汰过期数据 | volatile-lru | 只清理设置了过期时间的键 |
| 数据丢失不可接受 | noeviction | 保证数据安全,避免数据被删除 |
| 简单随机淘汰 | allkeys-random | 性能简单,但淘汰策略不具针对性 |
实现 LRU 算法(leetcode#146)
1 | class LRUCache extends LinkedHashMap<Integer, Integer>{ |