为什么Redis快
Redis 内部做了非常多的性能优化,比较重要的有下面 4 点:
纯内存操作 (Memory-Based Storage) :这是最主要的原因。Redis 数据读写操作都发生在内存中,访问速度是纳秒级别,而传统数据库频繁读写磁盘的速度是毫秒级别,两者相差数个数量级。
高效的 I/O 模型 (I/O Multiplexing & Single-Threaded Event Loop) :Redis 使用单线程事件循环配合 I/O 多路复用技术,让单个线程可以同时处理多个网络连接上的 I/O 事件(如读写),避免了多线程模型中的上下文切换和锁竞争问题。虽然是单线程,但结合内存操作的高效性和 I/O 多路复用,使得 Redis 能轻松处理大量并发请求(Redis 线程模型会在后文中详细介绍到)。
优化的内部数据结构 (Optimized Data Structures) :Redis 提供多种数据类型(如 String, List, Hash, Set, Sorted Set 等),其内部实现采用高度优化的编码方式(如 ziplist, quicklist, skiplist, hashtable 等)。Redis 会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡。
简洁高效的通信协议 (Simple Protocol - RESP) :Redis 使用的是自己设计的 RESP (REdis Serialization Protocol) 协议。这个协议实现简单、解析性能好,并且是二进制安全的。客户端和服务端之间通信的序列化/反序列化开销很小,有助于提升整体的交互速度。
与本地缓存比较
持久化机制
Redis支持 3 种持久化方式:
RDB:快照(snapshotting)
AOF:只追加文件(append-only file)
RDB 和 AOF 的混合持久化(Redis 4.0 新增)
RDB
RDB就是一种数据副本文件。
Redis 可以通过创建快照来获得存储在内存里面的数据在 某个时间点 上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。
Redis 提供了两个命令来生成 RDB 快照文件:
save: 同步保存操作,会阻塞 Redis 主线程;bgsave: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
AOF
AOF就是保存每条更改数据的指令操作。
开启 AOF 持久化后,每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再写入到 系统内核缓存区,最后再根据持久化方式( fsync策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。
三种写盘策略:
appendfsync always:执行写操作后,会立刻调用fsync函数同步 AOF 文件(刷盘)。主线程会阻塞,直到fsync将数据完全刷到磁盘后才会返回。这种方式数据最安全,理论上不会有任何数据丢失。但因为每个写操作都会同步阻塞主线程,所以性能极差。appendfsync everysec:执行写操作后立即返回,由后台线程(aof_fsync线程)每秒钟调用fsync函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒)。性能基本不受影响。在性能和数据安全之间做出了绝佳的平衡。不过,在 Redis 异常宕机时,最多可能丢失最近 1 秒内的数据。appendfsync no:让操作系统决定何时进行同步。 性能最好,避免了fsync的阻塞。但数据安全性最差,宕机时丢失的数据量不可控,取决于操作系统上一次同步的时间点。
两种方案比较
RDB 优点:
存储占用小:RDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。
恢复速度快:使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。
AOF 优点:
安全性好:AOF 支持秒级数据丢失(取决 fsync 策略,如果是 everysec,最多丢失 1 秒的数据),仅仅是追加命令到 AOF 文件,操作轻量。
可读性好:AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,你也可以直接操作 AOF 文件来解决一些问题。比如,如果执行
FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。
综上:
Redis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。
不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。
如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化
生产问题
缓存穿透
大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中。导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接导致宕机。
解决方案:
缓存无效的key:将不存在的key缓存null值在redis,避免每次都去数据库查询。但也可能会导致redis缓存大量无效key。
布隆过滤器:由位数组和一系列哈希函数组成。如果布隆过滤器未命中,则一定是无效查询,直接返回错误信息。存在则有可能是有效查询。
接口限流:根据用户或者IP进行限流,禁止异常频繁的访问。(对击穿和雪崩问题也有用)
缓存击穿
请求的 key 对应的是 热点数据,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接导致宕机。
解决方案:
永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。
提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间,比如秒杀场景下的数据在秒杀结束之前不过期。
互斥锁查询(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存,避免同时大量请求都去数据库查询。
逻辑过期:设置逻辑过期时间,不真正删除缓存。如果缓存过期,则使用互斥锁查询数据库更新缓存,竞争锁失败则直接返回逻辑缓存数据。
缓存雪崩
缓存在同一时间大面积的失效, 好比雪崩一样,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力,可能直接导致宕机。
解决方案:
设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。
提前预热(推荐):同上。
永不过期(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。
逻辑过期:同上。
缓存预热实现
常见的缓存预热方式有两种:
使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
一致性问题
采用旁路缓存模式(Cache Aside Pattern)解决:
读操作:
先尝试从缓存读取数据。
如果缓存命中,直接返回数据。
如果缓存未命中,从数据库查询数据,将查到的数据放入缓存并返回数据。
写操作:
先更新数据库。
再直接删除缓存中对应的数据。
关键是写操作要先更新数据库,再删缓存。
如果第二步删除缓存失败,则应该采用 重试 机制,直至成功为止。
重试:
重试面临可能会再次失败、重试次数多少次合理、重试会占用线程资源等问题。所以应该采用异步重试,可以把重试请求写到 消息队列 中,然后由专门的消费者来重试,直到成功。或者 订阅变更日志 ,如MySQL的binlog,根据操作的数据删除对应的缓存。
读写分离 + 主从库延迟 问题:
线程 A 更新主库 X = 2(原值 X = 1)
线程 A 删除缓存
线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
从库「同步」完成(主从库 X = 2)
线程 B 将「旧值」写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也发生不一致。
采用延迟双删策略:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。
但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?
问题1:延迟时间要大于「主从复制」的延迟时间
问题2:延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
但是,这个时间在分布式和高并发场景下,其实是很难评估的。很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。
结论:
推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。