数据结构
数据结构分为五类:
| 结构类型 | 结构存储 | 读写能力 | 应用场景 |
|---|---|---|---|
String(字符串) | 可以存储字符串,整数或浮点数 | 对字符串或部分进行处理,对整数或者浮点数进行自增或自减 | 缓存对象,分布式锁 |
Hash(哈希) | 存储键值对的无序散列表 | 添加,删除,查找单个元素 | 缓存对象,购物车 |
List(列表) | 链表 | 对两端进行push和pop,查找,删除 | 消息队列 |
Set(集合) | 存储字符串的无序集合 | 字符串的集合,增删改查,计算交集 | 聚合计算(交并差) |
Zset(有序集合) | 存储键值对的有序集合 | 保存元素与元素权重,元素的排列顺序根据元素权重的大小决定 | 排序场景(排行榜等) |
后续四类
| 结构类型 | 应用场景 |
|---|---|
BitMap | 二值状态统计的场景,比如判断用户是否登录 |
HyperLogLog | 海量数据技术统计,比如百万级计数 |
GEO | 存储地理位置信息 |
Stream | 消息队列:自动生成全局唯一消息ID,支持以消费组来消费信息 |
ZSet
底层实现:
ListPack:当元素个数小于128个且元素的值小于64字节时,使用ListPack。
一个“超级紧凑的数组”,把数据与数据长度塞进连续的内存空间中
encoding:定义元素的编码类型data:数据len:数据长度
跳表:多层的有序链表,可以快速定位数据。数据量很大时复杂度为O(logn)
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode <span class="token operator">*backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
//前向指针
struct zskiplistNode *forward;
//跨度
unsigned long span;
} level[];
} zskiplistNode;
头节点层高为设置的最大限高。
当创建节点时,层高随机而定:生成一个范围[0,1]的随机数,当小于0.25时,增加一层,直到大于0.25,直到最大限高。
跳表与B+树
- 实现复杂度:B+ 树需处理节点分裂/合并,代码复杂;
- 内存操作优势:跳表是链表结构,插入/删除只需修改指针,符合缓存局部性,无需像 B+ 树那样移动大量数据;
- 查询足够高效:O(log N) 已满足绝大多数场景。
String
SDS数据结构:
成员变量:
len:字符串长度,只要返回该变量即可获得长度,复杂度为O(1)alloc:分配的空间大小,若空间不够,则先将空间扩充,再进行修改。可以通过控制空间大小避免溢出。flags:SDS类型buf[]:存放数据
线程模型
总共有7个线程:
- Redis_Server(主线程):接受客户端请求----->解析请求----->进行数据处理----->返回响应(由于所有主要操作都只有一个线程完成,所以可以说Redis是单线程)
- 后台线程:(相当于消费者,生产者生产耗时任务,丢入消费队列,后台线程不停轮询并取出实现)
bio_close_file(关闭文件)bio_aof_fsync(AOF刷盘)bio_lazy_free(异步释放Redis内存):
- I/O线程:
io_thd_1,io_thd_2,io_thd_3:对于网络I/O操作进行多线程操作,缓解压力。
原子性
Redis执行一条命令时是原子的,因为单线程
执行一堆指令:使用Lua脚本,Redis本身不提供回滚功能。
内存淘汰
当内存满了的时候,淘汰一些不必要的内存资源,以腾出空间
八种:
- 不进行数据淘汰的策略:
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。
- 进行数据淘汰的策略:
- 在设置了过期时间的数据中进行淘汰:
volatile-random:随机淘汰设置了过期时间的任意键值;volatile-ttl:优先淘汰更早过期的键值。volatile-lru(Redis3.0 之前,默认的内存淘汰策略)淘汰所有设置了过期时间的键值中,最久未使用的键值;volatile-lfu:淘汰所有设置了过期时间的键值中,最少使用的键值;
- 在所有数据范围内进行淘汰:
allkeys-random:随机淘汰任意键值;allkeys-lru:淘汰整个键值中最久未使用的键值;allkeys-lfu:淘汰整个键值中最少使用的键值。
- 在设置了过期时间的数据中进行淘汰:
过期删除
惰性删除+定期删除:
- 惰性删除:当Redis访问数据时判断,如果已过期则删除(参数设置控制同步还是异步),然后返回null。
- 定期删除:每隔一段时间(默认10秒)随机抽取一部分数据进行检查,删除其中过期的数据
- 抽取20个数据(写死的)进行检查,删掉其中过期的。
- 如果删除的数据超过5个(25%),则重新抽取,继续删,直至少于25%。
日志
AOF日志
每执行一条写操作命令就以追加的形式写入一个文件里,当Redis重启时读取文件中的内容来恢复。
写回硬盘:
| 写回策略 | 写回时机 | 优点 | 缺点 |
|---|---|---|---|
Always | 同步写回(每次写操作执行完成) | 可靠性高,丢失最少 | 性能开销大 |
Everysec | 每秒写回(先写入AOF文件的内核缓冲区) | 性能适中 | 宕机时丢失一秒的数据 |
No | 由操作系统控制写回 | 性能最好 | 丢失数据多 |
RDB快照
将某一刻的内存数据以二进制形式存储进硬盘,可以直接读进内存。
使用两个命令:save:在主线程生成,会阻塞主线程;bg_save:创建一个子线程进行操作。
文件体积小,备份和恢复非常快。但间隔时间会很长
集群(cluster)
Redis集群采用无中心节点模式,客户端直接与所有节点相连。
各个节点之间使用gossip协议交换相互状态,并探测新节点信息
节点中采用哈希槽(Hash Slot):16384个,类似于数据分区,每个键值对都根据Key的哈希映射被分配到对应的节点。
gossip协议
随机传播
消息传播模式:
- 反熵:集群中的节点,每隔一段时间就随机选择一个节点,向他推送自己的全部信息,被接受方根据传来的信息对自己的信息进行修改,减少差距,达成一致。(一般应用中应设计成一个闭环,不能完全随机)
- 谣言传播:集群中的节点一旦获得新消息,就会变成活跃节点,,周期性联系其他节点,向其发送新数据,直到所有集群获得新数据。更适合节点数量多或节点动态变化的。
主从复制
主节点负责读写,从节点只负责读。从节点数据来自于主节点主从复制。
- 全量复制:在刚建立连接时只用全量复制。
- 主节点生成RGB全量快照文件,并将生成与后续同步期间的写操作记录在缓冲区。
- 从节点接收到文件,开始解析同步。
- 最后主节点将缓冲区的记录发给从节点,从节点一一接受并修改
- 增量复制:全量同步之后,每次主节点发生修改,就执行
replicationFeedSlaves()方法,将命令同步到从节点。replicationFeedSlaves()方法:作用为将修改发送到全部Slave,并执行。
哨兵模式
Redis的Sentinel系统用于管理多个Redis服务器。执行三个任务:
- 监控:不断检查Master与Slave是否正常工作。
- 提醒:当某个节点出问题,可向用户发出提醒
- 自动故障迁移:当一个Master不可用时,
- 将故障Master的一个Slave升级为新的Master。
- 让其他Slave复制新Master的值
- 通知用户新Master的地址。
推举算法:
场景
分布式锁
核心原理:用一个Redis Key作为“锁标识”,通过只有一个客户端能成功设置该值来实现互斥
即当这个客户端设锁时,向Redis设置一个“锁键”,释放锁时,删除这个锁键。其他客户端只有不存在锁键时才能设锁。
具体实现:依赖Set方法的两个参数:
- NX:全称:No Exist,即只有当键不存在才能设置成功(保证互斥)
- PX:设置过期时间(防止死锁)
- unique_value:客户端标识,释放锁时要先判断锁是否为当前客户端,避免误删。
SET lock_key unique_value NX PX 10000
Redisson
Redis的一个Java客户端,实现了强大且方便的分布式锁相关实现(原子释放,自动续命等功能)
可重入锁
-- 锁键:KEYS[1] = "lock:stock"
-- 客户端标识:ARGV[1] = "8743c9c0-0795-4907-87fd-6c719a6b4586:1"(包含UUID+线程ID)
-- 锁过期时间:ARGV[2] = 30000(默认30秒,看门狗默认时间)
-- 1. 如果锁不存在,直接设置锁(NX),并设置过期时间(PX)
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[1], 1);
redis.call('pexpire', KEYS[1], ARGV[2]);
return nil; -- 返回nil表示获取锁成功
end;
-- 2. 如果锁已存在,且持有者是当前客户端(可重入逻辑)
if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[1], 1); -- 重入次数+1
redis.call('pexpire', KEYS[1], ARGV[2]); -- 重置过期时间
return nil; -- 返回nil表示获取锁成功
end;
-- 3. 锁已被其他客户端持有,返回剩余过期时间
return redis.call('pttl', KEYS[1]);
核心实现:
- 锁键使用Hash结构:key=锁名,field=客户端标识,value=可重入数
- 客户端标识格式为UUID:线程数,可以保证区别JVM和线程的可重入
- 若获得失败,返回剩余过期时间。
看门狗(自动续命)
当未设置过期时间(使用lock())时启动。
当用户端获得锁之后,启动一个守护线程,每隔一段时间(过期时间的1/3)就重新检查,若任务仍未完成就增加过期时间。
锁默认30s过期,看门狗默认10s检查一次。(1/3)
流程:
- 客户端成功获得锁,看门狗线程启动,每隔10s发送命令延长时间(重置为30s)
- 当客户端完成任务或崩溃,看门狗线程自动销毁并释放锁
等待锁
原生分布式锁等待采用轮询重试方式(每100ms重新申请一次),会浪费资源。
Redisson采用消息通知+轮询兜底:
- 当线程B试图获得锁失败,订阅解锁消息Channel
- A释放锁,就会向该Channel发布消息,B收到后重新申请
- 如果时间过长,B也会采用轮询彩瓷尝试
减少了无效动作,保证了可靠性。
红锁
在集群中,当主节点宕机可能导致锁丢失,红锁(RedLock)通过“多节点投票”是心啊
关键细节:
- 获得锁时,向所有节点发送请求,只有超过半数节点获得且总耗时不超过过期时间即获得锁成功
- 释放锁时同样向所有节点发送通知
安全性高,但性能低。
大Key
某个key的value值过大,导致内存不够等各种问题。
如何处理:
- 定期处理:
- 拆分大Key:将部分巨大的Hash拆成多个小Hash
- 移除大Key:移到其他存储中
热Key
某个Key的被请求频率极高(QPS,带宽等)
如何解决:
- 二级缓存:使用本地缓存,将热key存到本地缓存中,每次请求从本地请求而非麻烦Redis
- 集群中分散到不同服务器中,做多个备份;或者读写分离
- 拆分
缓存读写
Cache Aside Pattern(旁路缓存模式)
最常⽤、适合读多写少的业务场景。
“旁路”(Aside):应⽤程序的写操作完全绕过了缓存,直接操作数据库。
写操作 :
- 应⽤先更新 DB。
- 然后直接删除 Cache中对应的数据。
读操作:
- 应⽤先从 Cache 读取数据。
- 如果命中(Hit),则直接返回。
- 如果未命中(Miss),则从 DB 读取数据,成功读取后,将数据写回 Cache,然后返回。
关键问题
**1.为什么写操作是“**先更新 DB,后删除 Cache”?顺序能反过来吗?
答: 绝对不能。如果“先删 Cache,后更新 DB”,在⾼并发下会引⼊经典的数据不⼀致问题。
时序分析 (请求 A 写, 请求 B 读**):**
- 请求 A: 先将 Cache 中的数据删除。
- 请求 B: 此时发现 Cache 为空,于是去 DB 读取旧值,并准备写⼊ Cache。
- 请求 A : 将新值写⼊ DB。
- 请求 B: 将之前读到的旧值写⼊了 Cache。
结果: DB 中是新值,⽽ Cache 中是旧值,数据不⼀致。
2. 那**“**先更新 DB,后删除 **Cache”**就绝对安全吗?
答案: 不是绝对安全
时序分析 (请求 A 读, 请求 B 写**):**
- 请求 A : 缓存未命中,从 DB 读取到旧值。
- 请求 B: 迅速完成了 DB 的更新,并将 Cache 删除。
- 请求 A : 将⾃⼰之前拿到的旧值写⼊了 Cache。
结果: DB 中是新值,Cache 中⼜是旧值。
为什么概率极⼩? 这个问题本质上是⼀个并发时序问题:只要“读 DB → 写 Cache”这段时间窗⼝内,恰好有写请求完成了 DB 更新,就有可能产⽣不⼀致。在⼤多数业务⾥,这个窗⼝时间相对较短,⽽且还需要与写请求并发“撞⻋”,所以发⽣概率不算⾼,但绝不是不可能。
3. 为什么是**“删除 Cache”,⽽不是“**更新 Cache”?
- 性能开销: 写操作往往只更新了对象的部分字段,如果为了“更新 Cache”⽽去重新查询或计算整个缓存对象,开销可能很⼤。相⽐之下,“删除”是⼀个轻量级操作。
- 并发安全: “更新缓存”在⾼并发下可能出现更新顺序错乱的问题导致脏数据的概率会更⼤。
现在我们再来分析⼀下 Cache Aside Pattern 的缺陷。
-
缺陷 1:⾸次请求数据⼀定不在 Cache 的问题。解决办法:缓存预热
-
缺陷 2:写操作⽐较频繁的话导致 Cache 中的数据会被频繁被删除,这样会影响缓存命中率 。解决办法:更新 DB 的时候同样更新 Cache,不过
-
数据强⼀致场景:需要加⼀个锁来保证更新 Cache 的时候不存在线程安全问题。
-
短暂允许不⼀致场景:给缓存加⼀个⽐较短的过期时间(如 1 分钟),这样的话就可以保证即使数据不⼀致的话影响也⽐较⼩。
-
Read/Write Through Pattern(读写穿透)
应⽤程序将Cache 视为唯⼀的、主要的存储。所有的读写请求都直接打向Cache,⽽ Cache 服务⾃⾝负责与 DB 进⾏数据同步。
对应⽤程序透明,应⽤开发者⽆需关⼼ DB 的存在。
写(Write Through):
- 先查 Cache,Cache 中不存在,直接更新 DB。
- Cache 中存在,则先更新 Cache,然后 Cache 服务⾃⼰更新 DB。只有当 Cache 和 DB 都写⼊成功后,才向上层返回成功。
读**(Read Through)**:
- 应⽤从 Cache 读取数据。
- 如果命中,直接返回。
- 如果未命中,由Cache 服务⾃⼰负责从 DB 加载数据,加载成功后先写⼊⾃⾝,再返回
Write Behind Pattern(异步缓存写⼊)
Write Behind 只更新缓存,不直接更新 DB,⽽是改为异步批量的⽅式来更新 DB。
写操作 (Write Behind):
- 应⽤将数据写⼊ Cache,然后⽴即返回。
- Cache 服务将这个写操作放⼊⼀个队列中。
- 通过⼀个独⽴的异步线程/任务,将队列中的写操作批量地、合并地写⼊ DB。
这种模式对数据⼀致性带来了挑战(例如:Cache 中的数据还没来得及写回 DB,系统就宕机了),因此不适⽤于需要强⼀致性的场景(如交易、库存)。
但是,它的异步和批量特性,带来了⽆与伦⽐的写性能。它在很多⾼性能系统中都有⼴泛应⽤:
⾼频计数场景: 对于⽂章浏览量、帖⼦点赞数这类允许短暂数据不⼀致、但写⼊极其频繁的场景,可以先在 Redis 中快速累加,再通过定时任务异步同步回数据库。
缓存雪崩,击穿,穿透
- 缓存雪崩:大批量数据同时过期或Redis宕机,此时若有大量用户请求,会全部打到数据库导致一系列事故。
- 均匀设置过期时间
- 互斥锁:保证同一时间内只有一个请求可以访问该资源
- 缓存击穿:某个热点数据过期,同时大量请求打来,导致全部打到数据库,导致宕机
- 热点不设置过期时间
- 互斥锁
- 缓存穿透:某个数据既不在缓存也不在数据库,全部达到数据库。
- 给空值设置独有逻辑(缓存空值)
- 非法请求控制
布隆过滤器
组成:一个位数组+多个哈希值
- 存入数据时,通过多个哈希函数计算得到多个哈希值
- 将哈希值与数组长度取模获得多个存放位置
- 将对应位置的值变成一
存在不一定存在,不存在一定不存在。
布隆过滤器扩容
保留原有的布隆过滤器,建立一个更大的,新增数据都放在新的布隆过滤器中,去重的时候检查所有的布隆过滤器。
附带时效的布隆过滤器
循环布隆过滤器:由多个布隆过滤器组成,定期清空最早的那个过滤器。
秒杀场景
- 秒杀请求达到:用户发起秒杀请求,系统接收到请求后,首先进行一些基础校验(如用户身份验证、活动是否开始等)。如果校验通过,进入库存扣减逻辑。
- Redis库存扣减:在Redis中检查商品库存是否充足。例如,使用
GET命令获取当前库存数量。如果库存不足,直接返回失败,结束流程。如果库存充足,使用Redis的原子操作(如DECR或Lua脚本)扣减库存。 - 异步更新数据库:如果Redis库存扣减成功,生成一个秒杀成功的消息,并将其放入消息队列
- 后台服务消费消息:后台服务从消息队列中消费秒杀成功的消息,执行以下操作:
- 为用户创建订单记录;
- 使用乐观锁将数据库中的库存数量减少1;
- 通过唯一标识(如用户ID+商品ID+时间戳)防止重复消费。
- 最终一致性校验:在Redis库存扣减和数据库库存更新之间,可能会存在短暂的不一致状态。为了保证最终一致性,可以采取以下措施:
- 定期将Redis中的库存数据与数据库进行同步。
- 如果发现Redis和数据库库存不一致,触发补偿逻辑(如回滚订单或调整库存)。