腾讯面试之言浅意深 Redid
最近工作上的比较忙,没时间写文章,因此更新的比较慢。
redis 与 memcached 的区别
1、数据类型:memcached 只支持字符串,而 redis 还支持列表 list,集合 set,排序集合 sortedSet,哈希,位图 bitMap 和 HyperLogLog 、Stream 流(redis 5.0 版本新特性)。
2、主从备份:redis 支持主从的模式应用,主数据库有多个副本,使得可以扩展数据库读取能力,且具有高可用性。
3、数据存储:memcached 与 redis 都支持数据存储在内存中,除此之外, redis 还支持将数据保存在磁盘中。
4、持久化:memcached 的数据只保存在内存,宕机后数据将丢失,而 redis 可利用持久化机制将数据保留在磁盘上,用于归档或恢复。
5、事务:redis 支持事务,可将一组命令原子操作地执行。
6、发布/订阅:redis 支持具有模式匹配的发布/订阅消息功能。
7、Lua 脚本:redis 允许执行事务性 Lua 脚本,帮助提高性能并简化应用代码。
8、地理空间支持:redis 具有专用命令,可以处理大规模实时地理空间数据,例如查找两个元素(人或地方)之间的距离以及查找点的给定距离内的所有元素。
9、线程模型:redis 使用的是单线程模型,而 memcached 使用的是多线程架构,可以利用多个核心及扩大计算能力来处理更多操作,在一定程度上性能会比 redis 优秀。
10、多语言客户端支持:redis 和 memcached 都支持多种语言客户端,包括 Java、Python、Php、C、C ++、Go等。
redis 有哪些数据类型
字符串 String、列表 list、集合 set、有序集合 sortedSet、哈希 hash、bitMap 位图、HyperLogLog、Stream 流(redis 5.0 新特性)。
redis 的使用场景有哪些?
1、热点数据缓存:对于系统中常用且不常更新的数据可加载到 redis ,提升性能。
2、分布式锁:结合 setexnx命令实现或者直接用 Redisson 的功能。
3、排行榜:使用 Sorted Sets 轻松实现游戏排行榜。
4、队列: redis 的 list 底层是链表,但同时也可用于队列,使用 lpush 、brpop 命令操作队列。
5、计数器:控制一个手机号一天限制发送 5 条短信,或用于库存扣减,保证不超发。
6、布隆过滤器:快速准确判断 10 万个号码是否在 10 亿个号码库里或者请求 IP 地址是否在 10 亿 的黑名单库。
7、GeoHash: 实现美团外卖或饿了么「附近的商家」功能,或者计算两个人之间的距离。
8、BitMap:使用位图可实现用户类似近 7 天签到功能或某时间范围内用户的登录状态。
9、延迟操作:例如订单在 30 分钟内未支付则自动取消并发送短信。
10、好友关系及点赞: set 集合可用于记录文章的点赞、阅读数;zset 实现好友关系,用 zinterstore 查询共同好友。
11、分布式限流:基于令牌桶算法,利用 Lua 脚本功能实现分布式限流,Spring Cloud Gateway中的限流就是典型例子。
redis 线程模型
在 redis 6 版本前均采用的是单线程模型,它基于 Reactor 模式开发了网络事件处理器,redis 在处理客户端的请求时,包括请求命令的获取、解析、执行、内容返回等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。
![redis 线程模型](/Users/keres_liu/文章/时序图/redis 线程模型.png)
但在 redis 6 版本后便正式引入多线程模型,随着越来越复杂的业务场景,需要更高的 QPS,常用的解决方案是对数据分区并采用更多的服务器,但该方案缺点是 投入的成本高,维护的 redis 服务器多。
在redis 执行期间,网络的读写及系统调用占用了大部分 CPU 时间,而瓶颈主要在于网络的 IO 消耗,因此优化的主要有以下原因:
- 充分利用服务器 CPU 资源,而目前 redis 主线程只能利用一个核。
- 多线程任务可以分摊 redis 同步 IO 读写负荷,例如:memcached。
redis 为何选择单线程网络模型?
- 使用单线程模型能带来更好的可维护性,方便开发与调试。
- 使用单线程模型也能并发的处理客户端的请求。
- 避免频繁的 CPU 上下文切换开销及多线程带来的安全同步。
- redis 服务中运行的绝大多数操作的性能瓶颈都不是 CPU。
从官方中给出的解析可以看到官方认为 redis 的大多数命令操作性能瓶颈并不在 CPU 之上,主要受限于内存和网络。
It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
因此,第三点起到决定性的因素,另外两点是使用单线程带来的好处。
redis 为什么这么”快“?
1、完全基于内存,绝大部分请求是纯粹的内存操作。
2、采用单线程,避免了不必要的上下文切换和竞争条件,但同时也无法利用多核的优势。
3、使用多路 I/O 复用模型,实现高吞吐的 IO 操作。
4、简单的数据结构,大多数读/写操作为 O(n) 。
使用 redis 的队列存在什么问题?
1、存在消息丢失的可能性。
2、生产速度与消费速度不匹配引起消息堆积,将会导致 redis 内存耗尽。
3、队列中的消息不允许重复消费。
redis 实现分布式锁
实现思想大致如下:
1、使用 setnx、setex 命令把当前获取锁的请求信息(锁的 key、线程 id等)保存到 redis,同时记录同个线程获取锁的次数(实现可重入功能)。
2、释放琐时,为避免误删,需判断当前操作的线程是否与加锁的是同一个,若是同一个则 del 对应锁的 key 即可。
涉及到多个命令执行,需把获取锁、释放锁的逻辑放在 lua 脚本中保证原子性,具体可参考 Redisson 分布式锁的实现:
获取锁逻辑代码:
1if (redis.call('exists', KEYS[1]) == 0) then
2 redis.call('hset', KEYS[1], ARGV[2], 1);
3 redis.call('pexpire', KEYS[1], ARGV[1]);
4 return nil;
5end;
6
7if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
8 redis.call('hincrby', KEYS[1], ARGV[2], 1);
9 redis.call('pexpire', KEYS[1], ARGV[1]);
10 return nil;
11end;
12
13return redis.call('pttl', KEYS[1]);
释放锁逻辑代码:
1if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
2 return nil;
3end;
4local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
5if (counter > 0) then
6 redis.call('pexpire', KEYS[1], ARGV[2]);
7 return 0;
8else
9 redis.call('del', KEYS[1]);
10 redis.call('publish', KEYS[2], ARGV[1]);
11 return 1;
12end;
13return nil;
当业务端未执行完业务,但持有锁的时间已到,如何处理。
可参考 Redisson 框架处理锁租期的方案,在每次获取锁时判断入参 leaseTime 是否为 -1 ,若是则在获取锁成功后新建一个线程专门检测对应锁的 key 是否过期,过期的话则调用 lua 脚本更新 key 的过期时间。
1private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
2 if (leaseTime != -1) {
3 return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
4 }
5 RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
6 ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
7 if (e != null) {
8 return;
9 }
10
11 // lock acquired
12 if (ttlRemaining) {
13 scheduleExpirationRenewal(threadId);
14 }
15 });
16 return ttlRemainingFuture;
17}
scheduleExpirationRenewal() 方法具体调用逻辑如下:
1private void renewExpiration() {
2 ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
3 if (ee == null) {
4 return;
5 }
6
7 Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
8 @Override
9 public void run(Timeout timeout) throws Exception {
10 ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
11 if (ent == null) {
12 return;
13 }
14 Long threadId = ent.getFirstThreadId();
15 if (threadId == null) {
16 return;
17 }
18
19 RFuture<Boolean> future = renewExpirationAsync(threadId);
20 future.onComplete((res, e) -> {
21 if (e != null) {
22 log.error("Can't update lock " + getName() + " expiration", e);
23 return;
24 }
25
26 // reschedule itself
27 renewExpiration();
28 });
29 }
30 }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
31
32 ee.setTimeout(task);
33}
通过代码发现每次锁续期完成后又会重新创建新线程刷新租期。
redis lua 实现分布式锁存在的问题
1、对设置有租约时间的客户端,当长时间阻塞将导致锁失效。
2、当 redis master 发生故障时,某 slave 升级为新 master,但锁信息未同步到新的 master ,导致其他请求能获取锁。
讲讲 RedLock 算法
对于 redis 主从漂移时,将导致锁失效的问题,redis 作者提出 RedLock 的算法:假设 redis 的部署模式是 redis cluster,总共有 3个 master 节点,加锁的时候,它会向多半节点发送 setex mykey myvalue
命令,只要过半节点成功,才算加锁成功。同样当释放锁的时候需要向所有节点发送 del 命令,感兴趣的可以查询相关资料(Redisson也实现了 RedLock )。
使用 Redlock 虽解决了 master 故障带来的同步问题,但它需要更多的 redis 实例资源,同时性能也会有一定的折损。
讲讲缓存穿透、击穿、雪崩
缓存穿透:指缓存和数据库中都没有的数据,而用户或攻击者不断发起请求,如发起为 userId 为负数或不存在的数据,将导致数据库压力过大,解决方案如下:
1、对参数值做有效性基本校验、用户鉴权等。
2、对缓存与数据库中都不存在的数据,可映射其 userId -> null 到缓存中,并根据业务场景设置过期时间,防止攻击者的暴力攻击。
缓存击穿:指缓存中没有数据,但数据库中有数据,一般是未做热加载或缓存过期导致,在某一刻由于并发查询同一条数据的请求特别多,读缓存无数据,因此同时去数据库查询数据,引起数据库压力瞬间增大,解决方案如下:
1、对于热点数据,设置其缓存过期时间更长或永久。
2、当查询缓存无数据时,使用互斥锁控制只允许一个线程 A 查询数据库,其余请求线程等待线程 A 加载数据到缓存,如Guava Cache在查询缓存无数据时,只允许一个线程加载。
缓存雪崩:指缓存中大批量数据到过期时间,同时查询数据量巨大,引起数据库压力过大甚至宕机。与缓存击穿不同的是,缓存击穿是围绕并发查询同一条数据,而缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库,解决方案如下:
1、设置缓存数据的过期时间随机,防止同一时间内大量数据发生过期。
2、对于热点数据,可设置其过期时间更长或永久。
谈谈缓存和数据库一致性问题
常见的缓存与数据库操作顺序有以下几种方式:
1、先写缓存,再更新 DB:
- 如果第一步更新缓存失败,直接返回,无影响。
- 如果缓存写成功,更新 DB 失败,此时若不清除缓存中已写入的数据,则会造成数据不一致(缓存中是新值,DB 中是旧值)。 如果增加清除缓存的逻辑,那么清除操作又失败了该如何处理?
2、先更新 DB,再写缓存:
- 如果更新 DB 失败,直接返回,无影响。
- 如果更新 DB 成功,缓存写入失败则会造成数据不一致(即 DB 中是新值,缓存中是旧值),如果重试写入缓存,那重试也失败该如何处理?
3、先删除缓存,再更新 DB。
- 如果删除缓存失败,直接返回,无影响。
- 如果删除缓存成功,更新 DB 失败,则会造成后续请求未命中缓存,则从数据库中回查数据。
4、先更新 DB,再删除缓存。
- 如果更新 DB 失败,直接返回,无影响。
- 如果更新 DB 成功,删除缓存失败则会造成数据不一致(DB 中是新值,缓存中是旧值)。
该问题本质上就是一个分布式数据一致性问题,在不要求强一致性的场景下,保证最终一致性即可,在更新完数据库后,通过订阅 MySQL 的 binlog 日志使缓存失效,若操作缓存失败时,把对应的缓存信息放至 MQ 重试。
如果对数据要求强一致性或无法接收脏数据,最简单的方式是不使用缓存,直接走数据库。
redis 持久化方式哪些?
1、RDB,全称 Redis Database,在指定的时间间隔内将内存中的数据集以快照的方式写入磁盘,实际操作过程是 fork 一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储,在恢复数据时将快照文件直接读到内存里。
优点:
- RDB 快照是压缩后的二进制文件,文件的大小会很小,比较适合使用全量复制与备份的场景。
- 相比于 AOF 机制,如果数据集很大,RDB 的恢复效率会更高。
缺点:
- 如果想保证数据的高可用性,即最大限度的避免数据丢失,那么 RDB 不是一个很好的选择,因为系统一旦在定时持久化之前出现宕机现象,没有来得及写入磁盘的数据都将丢失。
- 由于每次生成 RDB 快照都需要 fork 子进程生成全量数据的快照,占用 CPU 与磁盘资源,不适合于频繁执行。
- 兼容问题,不同版本的 redis 生成的快照可能不兼容。
2、AOF,全称为 Append Only File,将操作命令与数据以格式化的方式追加到操作日志文件的尾部,在 append 操作返回后(已经写入到文件或者即将写入),才进行实际的数据变更,日志文件保存了历史所有的操作过程,当 redis server 需要恢复数据时,可直接重放该日志文件,即可还原所有的操作过程。
在 redis 中提供了 每秒同步、每次修改同步、不同步 3 种同步策略。实际每秒同步是异步完成的,其效率高,一旦系统出现宕机,则这一秒内修改的数据将会丢失。
每次修改同步,即每次发生的数据变化都会被立即记录到磁盘中,可想而至,这种同步方式效率是最低的。
优点:
-
AOP 机制提供更高的数据安全性,即数据持久性。
-
AOF 持久化方式包含一个格式清晰、易于理解的日志内容,用于记录所有的修改操作。
-
对于写入了一半数据后出现了系统崩溃的现象,redis 能通过 redis-check-aof 工具帮助解决数据一致性的问题。
-
当日志文件过大时,redis 会启动 rewrite 机制,可以删除其中的某些命令。
缺点:
- 对相同数量的数据而言,AOF 文件通常要大于 RDB 文件,AOF 的恢复数据的速度要比 RDB 效率低。
- 根据同步策略的不同,AOF 在运行效率上通常会慢于 RDB,但每秒同步策略的效率是比较高的,禁用同步策略的效率和 RDB 效率类似。
对于选择哪种持久化方式,可根据系统能否接受部分性能的牺牲,通过 AOF 方式换取更高的数据一致性,或者禁用 RDB 备份换取更高的性能,待请求量或流量少的时间点再定时执行 save 命令做快照备份,但目前生产环境接触的更多都是二者结合使用的。
redis 部署方式有哪些
1、standaloan—单机模式,即只有一个 redis 实例,所有的服务都连接到该实例上,该模式不适用于生产环境,若 redis 实例发生宕机或内存不足等,将导致所有服务都受影响。
2、sentinel—哨兵模式,redis 官方推荐的高可用性方案,当使用 master-slave 模式时,在 master 宕机后,redis 本身是不具备自动主备切换的功能,而 redis-sentinel 是一个独立运行的进程,它能监控多个 master-slave 集群,发现 master 宕机后能自动切换并选举新的 master。
3、cluster—集群模式,随着业务和数据量剧增,已达到 redis 单节点性能瓶颈,垂直扩容受机器限制,水平扩容涉及对业务的影响,及数据迁移时存在数据丢失的风险,因此在 redis 3.0 推出 cluster 分布式集群方案,当遇到单节点内存、并发、流量瓶颈时,可采用cluster 方案实现负载均衡,该方案主要解决分片问题,把整个数据按照规则分成多个子集存储在多个不同 redis 节点上,每个节点各自负责整个数据的一部分。
redis 为何使用哈希槽而没用一致性 Hash ?
redis 集群没有直接使用一致性哈希,而是使用哈希槽,不同点就是对于哈希空间的定义,一致性哈希的空间是一个圆环,节点分布是基于圆环的,无法很好的控制数据分布,可能会产生数据倾斜问题。
而 redis 的槽位空间是自定义分配的,可以自定义大小,自定义位置的。redis 集群包含了 16384 个哈希槽,每个 Key 经过 CRC16 算法计算后会落在一个具体的槽位上,而槽位具体在哪个机器上是用户自己根据自己机器的情况配置的,机器硬盘小的可以分配少一点槽位,硬盘大的可以分配多一点。
另外在容错性和扩展性上与一致性哈希一样,都是转移受影响的数据。而哈希槽本质上是对槽位的转移,把故障节点负责的槽位转移到其他正常的节点上,扩展节点也是一样,把其他节点上的槽位转移到新的节点上。
数据迁移时,客户端访问数据,redis 会如何处理 ?
当数据迁移过程中,新旧节点对应的槽都存在部分数据,客户端首先尝试访问旧的节点,如果对应的数据在旧节点里,旧节点正常处理。
如果不在旧节点,则可能在新节点或者不存在。当客户端访问旧节点不存在时,会向客户端返回 ASK 或者 MOVED 重定向指令(其中 MOVED 是永久转向信号,ASK 则表示只需要这一次操作做转向),
需要注意的是,客户端查询新节点时,需要先发一条ASKING命令,否则这个针对带有 IMPORTING 状态的槽的命令请求将被新节点拒绝执行。
对于客户端,收到 MOVED 时,需要更新 slot 映射信息,当收到 ASK 时,则需要向新节点发 ASKING 命令并重新执行操作命令。
redis 过期数据清除机制
-
被动删除:当操作读/写一个已过期的 key 时,会触发惰性删除策略,检查 key 是否过期,若是直接删除过期 key 并且返回NIL。
-
主动删除:由于惰性删除策略无法保证冷数据被及时删掉,因此 redis 会定期主动淘汰清除已过期的 key。
redis 内存淘汰策略
当前已用内存超过 redis 配置的 maxmemory 限定时,会触发主动清理策略,策略包含以下几种:
- noeviction :不进行数据淘汰,当缓存被写满后,Redis不提供服务直接返回错误。
- volatile-random :在设置过期时间的键值对中随机删除。
- volatile-ttl :在设置过期时间的键值对,基于过期时间的先后进行删除,越早过期的越先被删除。
- volatile-lru:基于LRU(Least Recently Used) 算法筛选设置了过期时间的键值对, 最近最少使用的原则筛选数据。
- volatile-lfu:使用 LFU( Least Frequently Used ) 算法选择设置了过期时间的键值对, 使用频率最少的原则筛选数据
- allkeys-random:从所有键值对中随机选择并删除数据。
- allkeys-lru:使用 LRU 算法在所有数据中进行筛选。
- allkeys-lfu:使用 LFU 算法在所有数据中进行筛选。
线上 redis 实例内存不足,如何处理?
这是在面试腾讯音乐时被问到的问题,考察个人应急问题处理能力,首先第一要素是解决问题,即线上扩容,不能影响用户功能使用,但线上扩容只是解燃眉之急,后面数据增加后不可能继续扩容,毕竟成本摆在那里,因此可使用 redis cluster 方案把数据均衡的分布存储在不同的 redis 实例中,解决 redis 单实例存储过高的问题,但使用 redis cluster 方案也会引入一定的问题,例如某些命令不能在 cluster 下执行,增加数据迁移复杂度等。