数据库和缓存一致性

缓存和数据库一致性问题,是有很多种方案解决的,首先是旁路缓存,在写操作的时候先更新数据库,再删除缓存,读操作的时候如果没读到缓存就读数据库,再写入缓存。这样在高并发场景下也会出现一些问题,比如请求1执行写操作了,还没删除缓存,请求2读缓存了,请求2读到的就是旧数据。这个问题解决可以通过延迟双删解决,在写操作的时候先删除缓存,再更新数据库,过段时间再删除缓存。这样在线程1写操作的时候,其他线程读操作就无法命中旧数据的缓存了。但是这样高并发还是有一定的问题,首先在删除缓存准备修改数据库的时候,还没来及改,线程2查询发现没命中缓存,数据库查旧数据,更新缓存了,这个时候线程1更新数据库,还没来得及第二次删除缓存,又有读操作来了,又命中了旧数据。所以在写操作到延迟删缓存这段时间缓存还是有不一致问题,而且这个延迟时间无法确定设置多少,并且降低了吞吐量。如果业务对于并发情况数据一致性要求并没有那么高,上面两种方案其实就够用了,如果要保证强一致性问题就可以上分布式锁,在读写操作之前必须要获取到锁才能操作,线程1写操作的时候获取锁,这个时候其他线程就无法读,等线程1执行完更新操作删除了缓存,再释放锁,其他线程读操作获取到锁,查询数据库更新缓存释放锁。缺点就是强行改成了串行处理,性能非常差。还有一种保证最终一致性的方案就是使用阿里的Canal,订阅数据库的Binlog日志,当更新数据库的时候Binlog发生变更,Canal监听到消息删除缓存。优点是解耦了,缺点增加了运维成本,而且依赖Binlog,如果Canal 消费 Binlog 时可能失败(如网络波动),需对接消息队列(如 RabbitMQ),将删除缓存的请求存入队列,失败后重试,避免漏删。

Redis持久化

RDB存储的Redis数据的二进制格式,会每隔一段时间进行一次数据快照,保存rdb文件到磁盘,他的缺点是在两次快照之间如果重启或者网络波动会导致数据丢失。第二个AOF 以日志形式记录每一条写命令,需要设置刷盘策略到磁盘。Redis重启的时候重新执行日志中的写命令,实现持久化,缺点是数据量大了导致日志越来越大,恢复也会越来越慢。Redis4.0以后引入了混合持久化,通过RDB+AOF两种的方式达到一个不错的效果,融合 RDB 的 “快速恢复” 和 AOF 的 “数据完整” 优势,将全量数据用RDB用二进制存储,增量数据用AOF,以后统一放入AOF文件中,恢复先加载二进制的RDB再加载AOF。

Redis为什么快

  1. 纯内存操作:数据全存内存,避免磁盘 IO(数据库慢的核心原因),内存读写速度比磁盘快万倍以上;
  2. 单线程模型:无多线程上下文切换开销,也无需处理线程安全问题,减少性能损耗;
  3. 高效数据结构:底层用跳表、哈希表等结构,查询 / 插入 / 删除效率均为 O (1) 或 O (logn);
  4. IO 模型优化:用 IO 多路复用(epoll/kqueue),单线程处理上万并发连接,无连接阻塞;
  5. 轻量设计:代码简洁(核心代码仅几万行),无复杂逻辑,减少运行时资源占用。

讲一下缓存穿透,缓存击穿,缓存雪崩

缓存穿透就是因为一般设计业务的时候,会优先访问缓存中数据是否存在,如果不存在访问数据库,攻击者就是可以用这一点构造恶意请求,多次访问数据库,导致数据库压力过大。这种情况可以对访问请求进行合法性检查,过滤非法字符或者使用布隆过滤器过滤,再决定是否访问数据库。或者给redis设置null值或者空字符串。同时如果查询数据库没数据,将该key缓存并设置空值标识,比如__NULL__,防止业务支持空值,设置短期的过期时间,实际落地可以通过缓存空值+布隆过滤器解决缓存缓存穿透的问题,缓存空值能处理布隆过滤器的假阳性数据。

缓存击穿就是当缓存数据过期或者失效的时候,攻击者并发访问失效数据,这样会直接访问数据库,导致高并发,给数据库造成压力。解决方式可以在缓存失效或者过期前,进行预更新或者延迟更新,让攻击者不知道更新的时间。第二个就是解决并发攻击,这里可以使用锁,互斥锁和分布式锁,过期或者失效的时候有线程没有访问到缓存中数据,则给该线程一个锁, 这个时候就可以增加一个判断如果有锁,则查询数据,同时释放锁,并将数据更新到缓存,如果没有锁就线程等待。或者逻辑过期,添加逻辑时间字段,当现在请求接口的时候,先判断当前时间是否在过期时间之前,如果未过期,将数据直接返回,如果过期了,进行缓存重建,加上互斥锁,重新查找一次数据库,封装新的过期时间,将数据放入缓存中。那么在过期时间以后的所有线程,只有拿到锁的线程进行了缓存修改,后面线程发现时间都没过期,就拿修改后的数据。所以会有一个数据一致性的问题,在过期时间内的数据都是旧数据。

缓存雪崩是缓存中大量的数据全部失效,导致非常多的请求直接访问数据库,导致数据库压力剧增。最简单的是给每个key的TTL增加随机值,缓存预热的时候给缓存数据的设置过期时间TTL的时候定义一个范围,追加该范围的随机数。这种情况一般使用分布式集群提高可靠性或者限流,要么多级缓存。

布隆过滤器的问题

布隆过滤器的核心缺陷是存在假阳性(False Positive):即一个不存在于数据库中的 key,可能被误判为 “存在”。无法处理 “已删除数据” 的查询。商品 A 已下架(数据库删除),但布隆过滤器仍认为 “product:A” 存在,当缓存中 “product:A” 过期后,所有查询都会打到数据库查无结果。需要提前设计好预期存储的元素数量p和可接受的假阳性百分比,并计算出对应的P(数组长度)和K(哈希函数数量)。
假阳性解决

redis的分布式锁如何设计的

首先我在系统发起流程审批的时候通过redisson对流程节点ID加上了分布式锁,防止了因为某个节点被多端同时处理或者多用户同时审批引起的并发问题。之所以使用redisson是因为它是基于redis的分布锁方案,底层依旧是通过set NX上锁,lua脚本释放锁,底层lua脚本通过hash类型fieid为线程标识,key为锁重入次数,加锁和释放锁判断保证了锁的可重入性,并且它引入了看门狗机制,防止了网络问题导致锁提前过期,死锁的问题。主从一致性问题通过redlock保证,比如5个从节点要有三个拿到锁才算成功,增加了可靠性。

红锁是什么

Redisson分布式锁原理

Redisson 分布式锁是基于 Redis 实现的高性能、高可用分布式锁方案,核心原理可概括为:利用 Redis 的单线程特性 + 原子命令 + Lua 脚本保证锁的安全性,通过可重入设计、自动续期、集群容错等机制解决分布式场景下的各种问题 。

IO多路复用

image.png

先说下阻塞IO和非阻塞IO模型,它分为用户态,内核态和硬件,因为是在缓冲区中,要取数据就必然要从用户态切换到内核态拿数据。首先硬件会准备数据给内核态的内存缓存区里面,在阻塞模型用户缓冲区会等待数据就绪,阻塞等待,就绪以后再从内核缓冲区读数据。非阻塞IO则是一直尝试读取,失败继续读,直到数据就绪以后,成功读到数据。

image.png

IO多路复用就是将redis先当作用户态么,拿数据必然是要切换到内核态,从硬件拿取数据。内核空间中有一块内核缓冲区,需要等硬件设备准备好数据到内核缓冲区,用户空间才能拿数据,然后用户缓存区会通过FD文件描述符(file Description)寻找已经就绪的数据,然后再读数据。而用户态判断内核态数据是否准备好的这个过程是io多路复用中关键的几个机制,分别是select poll和epoll。
select是通过创建一个fd集合,然后拷贝到内核空间,内和空间再遍历发现就绪的就把就绪的FD返回给用户空间。因为涉及两次拷贝和遍历效率很差,而且集合空间最大才1024,意味着只能同时有1024个get操作。poll的机制和select差不多,但是文件描述符没有上限。epoll通过创建一个epoll实例,是在内核空间的,再添加需要监听的FD,也是在内核空间,等待就绪以后,放入内核空间中的一个列表,再拷贝到用户空间去。用户空间就可以直接读数据了。
image.png

对比维度 select poll epoll(推荐)
文件描述符上限 有上限(默认 1024) 无上限 无上限
IO 就绪通知方式 轮询(遍历所有描述符) 轮询(同 select) 事件驱动(只通知就绪的)
效率 低(随连接数增加下降) 低(同 select) 高(不受连接数影响)
数据拷贝 内核→用户空间(每次就绪) 内核→用户空间(每次就绪) 无需拷贝(共享内存)
epoll 是目前性能最优的实现,其工作流程可分为三步,核心是 “事件注册 + 内核通知”:
  1. 创建 epoll 实例:调用 epoll_create() 创建一个 epoll 对象(本质是内核中的一个事件表),用于管理需要监控的 IO 描述符。
  2. 注册事件:调用 epoll_ctl() 向 epoll 实例中注册需要监控的 IO 描述符,以及关注的事件类型(如 “读就绪”EPOLLIN、“写就绪”EPOLLOUT)。
  3. 等待事件就绪:调用 epoll_wait() 阻塞等待,内核会监控所有注册的描述符。一旦某个描述符就绪,内核会将其对应的事件加入到 “就绪事件列表”,并唤醒 epoll_wait(),最后应用程序从列表中获取就绪的描述符并处理 IO。

redis的pub/sub机制

Redis 的 Pub/Sub(发布 / 订阅)机制是一种基于消息传递的通信模式,用于实现多个客户端之间的异步通信。它的核心思想是将消息的发送者(发布者)和接收者(订阅者)解耦,发布者无需知道订阅者的存在,订阅者也无需关注消息的来源,只需关注自己感兴趣的消息类型(频道)。优点轻量级缺点消息可靠性不高,对于业务要求持久化或者复杂的消费模式还是使用常规的MQ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package com.uucc;  

import org.springframework.beans.factory.annotation.*;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.*;

@Service
public class GoodsService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private GoodsMapper goodsMapper;

private static final String CACHE_KEY = "goods:info:";
private static final String LOCK_KEY = "lock:goods:rebuild:";
// 缓存过期时间(30分钟)
private static final long CACHE_EXPIRE = 30;
// 重试次数和间隔
private static final int RETRY_COUNT = 5;
private static final long RETRY_INTERVAL = 100; // 100毫秒

public GoodsVO getGoodsInfo(Long goodsId) {
String cacheKey = CACHE_KEY + goodsId;
String lockKey = LOCK_KEY + goodsId;

// 1. 第一次查询缓存
GoodsVO goodsVO = (GoodsVO) redisTemplate.opsForValue().get(cacheKey);
if (goodsVO != null) {
return goodsVO; // 缓存有效,直接返回
}

// 2. 缓存无效,获取Redisson分布式锁
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁:最多等待0秒(不等待),持有锁30秒后自动释放(防止死锁)
boolean isLockSuccess = lock.tryLock(0, 30, TimeUnit.SECONDS);

if (isLockSuccess) {
// 3. 抢锁成功:双重检查缓存 + 重建
GoodsVO cacheAgain = (GoodsVO) redisTemplate.opsForValue().get(cacheKey);
if (cacheAgain != null) {
return cacheAgain; // 避免锁等待期间其他线程已重建
}

// 查数据库
Goods goods = goodsMapper.selectById(goodsId);
if (goods == null) {
// 数据库也没有,设置空缓存(避免缓存穿透)
redisTemplate.opsForValue().set(cacheKey, new GoodsVO(), 5, TimeUnit.MINUTES);
return new GoodsVO();
}

// 转换为VO并更新缓存(设置30分钟过期)
goodsVO = convertToVO(goods);
redisTemplate.opsForValue().set(cacheKey, goodsVO, CACHE_EXPIRE, TimeUnit.MINUTES);
return goodsVO;
} else {
// 4. 抢锁失败:重试查询缓存(等待重建完成)
int retry = 0;
while (retry < RETRY_COUNT) {
Thread.sleep(RETRY_INTERVAL);
GoodsVO retryGoods = (GoodsVO) redisTemplate.opsForValue().get(cacheKey);
if (retryGoods != null) {
return retryGoods; // 拿到新缓存,直接返回
}
retry++;
}
// 重试失败,返回兜底数据
return new GoodsVO();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断状态
return new GoodsVO(); // 异常兜底
} finally {
// 5. 只有持有锁的线程才需要释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}

// 实体转VO(省略实现)
private GoodsVO convertToVO(Goods goods) {
// ... 转换逻辑
}
}

Redis为什么使用跳表而非B树、B+树?

innodb用B+树做索引是为了减少磁盘IO,但是Redis是在内存,IO开销较小 。。。 。 

Redis Sentinel 哨兵

Redis哨兵是Redis高可用的解决方案,用来监控主从集群的,并在主节点如果出现了故障进行故障转移,他会通过心跳检测机制,检测主节点是否存活,同时Sentinel也是一个集群,当检测到主节点宕机了。当超过半数sentinel节点认为主节点宕机了就会触发故障转移,sentinel集群会采用投票选举制度选出一个从节点当作新的主节点。原来的主节点会被标记成客观下线。

Redis集群 hash槽水平扩展

Redis集群会涉及到水平扩展,一个集群会有多个主节点,每个主节点又会有至少一个从节点,主节负责写操作和存储,从节点负责读操作,默认异步同步,主节点通过数据分片,hash槽进行扩展,当新增主节点就会分配对应的hash槽,防止数据丢失。key是存放在hash槽的,这个hash槽属于哪个主节点,key就属于哪个主节点。适合数据量较大,并且场景复杂的业务。

hash槽

  • 16384 个槽对应 16384 个二进制位(bit),刚好是 2048 字节(2KB)(16384 ÷ 8 = 2048)。

实战

讲一下 Redis 的 set 底层

Redis 的 Set(集合)底层有两种实现:intset(整数集合)hashtable(哈希表)

  • Intset:当 Set 里的元素全部是整数,并且元素数量比较少(默认小于 512 个)时,Redis 会使用 intset 来存储。Intset 是一块连续的内存数组,查找是 O(logN)(二分查找),非常省内存。
  • Hashtable:当元素不全是整数,或者数量超过阈值时,就会升级为 hashtable。这就是一个标准的字典,Key 是元素值,Value 是 Null。查找是 O(1)。

Set 扩容前后的变化

Set 从 intset 转变为 hashtable 的过程,通常被称为编码转换

  • 扩容前 (Intset):数据是紧凑存储的,省内存,但插入和查找需要二分,随着数据量变大性能会下降。
  • 变化触发点
    1. 插入了一个非整数的字符串。
    2. 或者整数的数量超过了配置阈值(set-max-intset-entries,默认 512)。
  • 扩容后 (Hashtable):会创建一个新的字典,把 intset 里的所有整数读出来,转成 hashtable 的 Key 存进去,然后释放 intset。虽然内存占用变大了,但操作复杂度变成了 O(1),适合大数据量。

跳表 (Skip List)

Redis 的 ZSet (Sorted Set) 在元素较多或 value 较长时,底层使用的是 跳表 + 哈希表

  • 为什么要用跳表? 相比红黑树,跳表实现简单,且范围查询(Range Query)效率极高(ZSet 经常要做 ZRANGE)。
  • 结构
    • 底层是一个完整的双向链表,存了所有数据。
    • 上面建了很多层“索引层”(Express Lane)。第 1 层索引可能每隔 1 个节点抽一个,第 2 层每隔 4 个…
    • 查找时,从顶层索引开始“跳”着找,发现大了就下一层,最后到最底层定位。
  • 效率:平均查找复杂度 O(logN),和红黑树一样快,但更省内存。
  • 随机性:插入新节点时,通过抛硬币(随机数)决定这个节点要建几层索引,不需要复杂的旋转平衡操作。

Redis 的事务机制,对 Redis 的事务机制有什么了解?

Redis 的事务和 MySQL 的事务(ACID)完全不同,它比较“弱”。

  • 核心命令MULTI(开启)、EXEC(执行)、DISCARD(取消)、WATCH(乐观锁监控)。
  • 过程:输入 MULTI 后,你输的命令不会立马执行,而是被放进一个队列里。一旦输入 EXEC,Redis 就会一次性、按顺序执行队列里的所有命令。
  • 最大的区别(不支持回滚)
    • 如果命令语法有错(入队时报错),整个事务都不会执行(原子性)。
    • 但如果命令语法没错,但执行时报错(比如给 String 做自增),这条命令报错,其他命令依旧正常执行!Redis 不会回滚
  • 为什么不回滚? Redis 追求极致性能。它认为只有代码逻辑错误才会导致运行时报错,生产环境不该出现这种错,所以为了快,砍掉了复杂的回滚机制。

Redis缓存。你之前项目中有用到过方式吗?

使用过的,会将热点数据和频繁需要使用的数据查询到数据库以后放入缓存一份,这样以后的查询会走缓存。因为基于内存的,速度会快很多。不用和数据库做IO操作了。

Redis一般我们常用的有哪些数据结构?

首先是string类型,也是最常用的,他是二进制存储数据,比较安全,小字符串才用embstr编码,大字符串使用raw编码。常用来存储token和简单数据。接着是hash,他是存储的键值对集合,元素较少时才用压缩列表,较多时采用hashtable。常用来存用户信息和商品信息等等。list是列表,redis里是双向列表,短列表才用压缩列表,长列表使用linkedlist双向列表。可以用作 消息队列。set是无序不可重复集合,常用来去重复,整数集合使用intset其他类型使用的是hashtable,最后一个常用的zset,他是有序集合,每个元素会带有一个score值用来排序,底层才用跳表+哈希表。小集合用压缩列表。

有哪些你刚才介绍的一些使用场景,除了一些做缓存,做数据存储,还有其他的一些使用场景?

得益于redis的lua脚本保证原子性和中间件天然对在分布式项目中的可见性,可以用来做分布式锁。之前是常用的5种数据类型,redis还支持bitmap - Geospatial可以用来做经纬度,地理位置存储比如附近的人,和用来基数统计的数据结构- HyperLogLog,

你怎么保证缓存一致性?是缓存的数据和数据库的数据保持一致。

缓存和数据库一致性问题,是有很多种方案解决的,首先是旁路缓存,在写操作的时候先更新数据库,再删除缓存,读操作的时候如果没读到缓存就读数据库,再写入缓存。这样在高并发场景下也会出现一些问题,比如请求1执行写操作了,还没删除缓存,请求2读缓存了,请求2读到的就是旧数据。这个问题解决可以通过延迟双删解决,在写操作的时候先删除缓存,再更新数据库,过段时间再删除缓存。这样在线程1写操作的时候,其他线程读操作就无法命中旧数据的缓存了。但是这样高并发还是有一定的问题,首先在删除缓存准备修改数据库的时候,还没来及改,线程2查询发现没命中缓存,数据库查旧数据,更新缓存了,这个时候线程1更新数据库,还没来得及第二次删除缓存,又有读操作来了,又命中了旧数据。所以在写操作到延迟删缓存这段时间缓存还是有不一致问题,而且这个延迟时间无法确定设置多少,并且降低了吞吐量。如果业务对于并发情况数据一致性要求并没有那么高,上面两种方案其实就够用了,如果要保证强一致性问题就可以上分布式锁,在读写操作之前必须要获取到锁才能操作,线程1写操作的时候获取锁,这个时候其他线程就无法读,等线程1执行完更新操作删除了缓存,再释放锁,其他线程读操作获取到锁,查询数据库更新缓存释放锁。缺点就是强行改成了串行处理,性能非常差。还有一种保证最终一致性的方案就是使用阿里的Canal,订阅数据库的Binlog日志,当更新数据库的时候Binlog发生变更,Canal监听到消息删除缓存。优点是解耦了,缺点增加了运维成本,而且依赖Binlog,如果Canal 消费 Binlog 时可能失败(如网络波动),需对接消息队列(如 RabbitMQ),将删除缓存的请求存入队列,失败后重试,避免漏删。

那先写数据库和先写缓存会有什么问题?

假如先写缓存,再写数据库是两步操作,假如在写数据库的时候失败了,缓存更新了,数据库是旧值,后续都命中缓存了,读到都是脏数据。先写数据库再写缓存,会有并发安全性的问题,比如线程1更新缓存100-200,线程2也过来了更新缓存为300,并强行修改数据库为300.后续线程1再更新数据库为200。那现在缓存是300,数据库是200。也是有问题的。

Redis 过期策略和内存淘汰算法。

过期策略采用惰性删除和定期删除,当主动访问某个键的时候就会检查有没有被删除过,如果删除了就清理。缺点就是如果有很多过期了但是迟迟没有访问的数据就会占用空间。所以采用了定期删除,每隔一段时间清理过期的数据。内存淘汰算法,主要是LRU,LFU和TTL,最常用的是近似LRU的算法,淘汰冷key,LFU也是淘汰访问频率最低的键,TTL就是淘汰过期时间最短的键,还有一种随机淘汰键。但是只适用于数据分布均匀访问频率也均匀的情况。

Redis 的使用场景有哪些?

作缓存减少数据IO,增加接口性能提高QPS,作分布式锁,在分布式场景通过中间件的天然可见性保证并发安全性。得益于后续更新的数据类型,也可以做会话存储和消息队列、地理位置服务、位图、排行榜等场景。

什么样的数据适合放入缓存?

访问频率高的数据,放入缓存可以降低数据库IO压力。读多写少的数据,这样能尽可能减少缓存一致性失败的情况。还有一些非安全性的数据,数据量比较小的,因为缓存毕竟是内存中的么,一些很重要的数据放缓存中还是有一定危险性的。

怎么处理热 key

先通过redis-cli hotkeys找到对应的部分热key,可以采用双层缓存,在本地也缓存一部分热key,再回溯到redis,降低qps。也可以热key分片,放到不同的节点,分摊访问的压力,上redis集群通过hash槽,将热 Key 所在槽位分散到更多节点。还可以提前预热和Sentinel做限流。

怎么处理大key

同样要先扫描redis中的大key,核心处理方法就是拆成小key,然后分批处理。同时在开发的时候要规范好每种数据类型对应的大key限制内存大小,监控层面也要实时监控,及时发现处理大key。

Redis 持久化方案有哪些?(RDB 和 AOF,RDB 和 AOF 的优缺点分别是什么?

RDB和AOF,AOF存储的是redis写入操作的时候的命令,比如SET name youki 会把命令写入一个rdb文件中,重启的时候,执行该文件,达到回复数据的目的。他的缺点就是,随着使用越来越多,该文件也会越来越大。并且随着文件变大,重启的时候加载就会很慢。第二个是RDB,redis通过save和bgsave存储当前数据快照,可以修改配置文件,每隔一段间隔自动进行一次快照,RBD是存储当前数据的快照,他是以二进制存储的,所以再重启的时候,恢复速度比较快,但是也有缺点因为是拍的数据快照所以在两次数据快照之间,如果重启了会导致数据丢失。后续4.0以后采用了混合持久化,他会在AOF重写日志的时候,fork出来的子线程将RDB内存数据写入AOF文件,再将后续增加的数据AOF写入AOF文件中,这样新的AOF数据就是前半部分是RDB快照,后半部分是AOF增量数据,把两者优点结合了。缺点可读性比较差,不兼容4.0之前的版本。

Redis Sentinel 哨兵

Redis哨兵是Redis高可用的解决方案,用来监控主从集群的,并在主节点如果出现了故障进行故障转移,他会通过心跳检测机制,检测主节点是否存活,同时Sentinel也是一个集群,当检测到主节点宕机了。当超过半数sentinel节点认为主节点宕机了就会触发故障转移,sentinel集群会采用投票选举制度选出一个从节点当作新的主节点。原来的主节点会被标记成客观下线。

Redis集群 hash槽水平扩展

Redis集群会涉及到水平扩展,一个集群会有多个主节点,每个主节点又会有至少一个从节点,主节负责写操作和存储,从节点负责读操作,默认异步同步,主节点通过数据分片,hash槽进行扩展,当新增主节点就会分配对应的hash槽,防止数据丢失。key是存放在hash槽的,这个hash槽属于哪个主节点,key就属于哪个主节点。适合数据量较大,并且场景复杂的业务。

hash槽

  • 16384 个槽对应 16384 个二进制位(bit),刚好是 2048 字节(2KB)(16384 ÷ 8 = 2048)。

Redis的IO多路复用

IO多路复用就是用一个线程同时监听多个网络连接,哪个连接有数据来了就处理哪个,不用为每个连接单独开线程。传统BIO是每个连接一个线程,连接多了线程数爆炸,上下文切换开销大。Redis是单线程处理命令,但用IO多路复用处理网络IO,这样既能处理大量并发连接,又避免了多线程的复杂性和锁的开销。底层实现Linux下用的是epoll,epoll用事件通知机制,只有有事件的连接才会被处理,比select和poll效率高。select和poll需要遍历所有连接,连接多了性能差,epoll用红黑树和双向链表,只关注有事件的连接,效率高很多。Redis单线程+IO多路复用,既保证了高性能,又避免了多线程的线程安全问题。

讲一下缓存穿透,缓存击穿,缓存雪崩

缓存穿透就是因为一般设计业务的时候,会优先访问缓存中数据是否存在,如果不存在访问数据库,攻击者就是可以用这一点构造恶意请求,多次访问数据库,导致数据库压力过大。这种情况可以对访问请求进行合法性检查,过滤非法字符或者使用布隆过滤器过滤,再决定是否访问数据库。或者给redis设置null值或者空字符串。同时如果查询数据库没数据,将该key缓存并设置空值标识,比如__NULL__,防止业务支持空值,设置短期的过期时间,实际落地可以通过缓存空值+布隆过滤器解决缓存缓存穿透的问题,缓存空值能处理布隆过滤器的假阳性数据。

缓存击穿就是当缓存数据过期或者失效的时候,攻击者并发访问失效数据,这样会直接访问数据库,导致高并发,给数据库造成压力。解决方式可以在缓存失效或者过期前,进行预更新或者延迟更新,让攻击者不知道更新的时间。第二个就是解决并发攻击,这里可以使用锁,互斥锁和分布式锁,过期或者失效的时候有线程没有访问到缓存中数据,则给该线程一个锁, 这个时候就可以增加一个判断如果有锁,则查询数据,同时释放锁,并将数据更新到缓存,如果没有锁就线程等待。或者逻辑过期,添加逻辑时间字段,当现在请求接口的时候,先判断当前时间是否在过期时间之前,如果未过期,将数据直接返回,如果过期了,进行缓存重建,加上互斥锁,重新查找一次数据库,封装新的过期时间,将数据放入缓存中。那么在过期时间以后的所有线程,只有拿到锁的线程进行了缓存修改,后面线程发现时间都没过期,就拿修改后的数据。所以会有一个数据一致性的问题,在过期时间内的数据都是旧数据。

缓存雪崩是缓存中大量的数据全部失效,导致非常多的请求直接访问数据库,导致数据库压力剧增。最简单的是给每个key的TTL增加随机值,缓存预热的时候给缓存数据的设置过期时间TTL的时候定义一个范围,追加该范围的随机数。这种情况一般使用分布式集群提高可靠性或者限流,要么多级缓存。

布隆过滤器的问题

布隆过滤器的核心缺陷是存在假阳性(False Positive):即一个不存在于数据库中的 key,可能被误判为 “存在”。无法处理 “已删除数据” 的查询。商品 A 已下架(数据库删除),但布隆过滤器仍认为 “product:A” 存在,当缓存中 “product:A” 过期后,所有查询都会打到数据库查无结果。需要提前设计好预期存储的元素数量p和可接受的假阳性百分比,并计算出对应的P(数组长度)和K(哈希函数数量)。
假阳性解决:通过对数据量的估算和允许的假阳性概率,计算出对应的hash函数,让哈希分布更均匀减少hash碰撞的发生。无法被删除,可以用redis做一个list列表,里面放删除的key做兜底,如果判断布隆过滤器为true,再判断列表中是否被删除,被删除了就直接返回。

redis的分布式锁如何设计的

首先我在系统发起流程审批的时候通过redisson对流程节点ID加上了分布式锁,防止了因为某个节点被多端同时处理或者多用户同时审批引起的并发问题。之所以使用redisson是因为它是基于redis的分布锁方案,底层依旧是通过set NX上锁,lua脚本释放锁,底层lua脚本通过hash类型fieid为线程标识,key为锁重入次数,加锁和释放锁判断保证了锁的可重入性,并且它引入了看门狗机制,防止了网络问题导致锁提前过期,死锁的问题。主从一致性问题通过redlock保证,比如5个从节点要有三个拿到锁才算成功,增加了可靠性。