MySQL和Redis是项目中常用的中间件.
Redis
数据类型、底层结构以及使用场景
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)
随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
应用场景:
- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
- Hash 类型:缓存对象、购物车等。
- Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
- HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
- GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
- Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。
字符串是最基本的数据类型,可以存储文本、数字或者二进制数据,最大容量是 512 MB。适合缓存单个对象,比如验证码、token、计数器等。
列表是一个有序的元素集合,支持从头部或尾部插入/删除元素,常用于消息队列或任务列表
哈希是一个键值对集合,适合存储对象,如商品信息、用户信息等。比如说 value = {name: '沉默王二', age: 18}。
集合是无序且不重复的,支持交集、并集操作,查询效率能达到 O(1) 级别,主要用于去重、标签、共同好友等场景。
有序集合的元素按分数进行排序,支持范围查询,适用于排行榜或优先级队列。
Bitmap 可以把一组二进制位紧凑地存储在一块连续内存中,每一位代表一个对象的状态,比如是否签到、是否活跃等。
Redis常用命令
Redis 支持多种数据结构,常用的命令也比较多,比如说操作字符串可以用 SET/GET/INCR,操作哈希可以用 HSET/HGET/HGETALL,操作列表可以用 LPUSH/LPOP/LRANGE,操作集合可以用 SADD/SISMEMBER,操作有序集合可以用 ZADD/ZRANGE/ZINCRBY等,通用命令有 EXPIRE/DEL/KEYS 等。
持久化
为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。Redis 共有三种数据持久化的方式:
- AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
- RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;
RDB
RDB 持久化机制可以在指定的时间间隔内将 Redis 某一时刻的数据保存到磁盘上的 RDB 文件中,当 Redis 重启时,可以通过加载这个 RDB 文件来恢复数据。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:
- 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;
- 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞
也可以在 Redis 配置文件中设置 RDB 持久化参数 save <seconds> <changes>,表示在指定时间间隔内,如果有指定数量的键发生变化,就会自动触发 RDB 持久化。
第二种,主从复制时,当从节点第一次连接到主节点时,主节点会自动执行 bgsave 生成 RDB 文件,并将其发送给从节点
RDB 通过 fork 子进程在特定时间点对内存数据进行全量备份,生成二进制格式的快照文件。其最大优势在于备份恢复效率高,文件紧凑,恢复速度快,适合大规模数据的备份和迁移场景.
缺点是可能丢失两次快照期间的所有数据变更。
AOF 会记录每一条修改数据的写命令。这种日志追加的方式让 AOF 能够提供接近实时的数据备份,数据丢失风险可以控制在 1 秒内甚至完全避免。
缺点是文件体积较大,恢复速度慢。
AOF
Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。
AOF刷盘策略
- 写回硬盘的策略, 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
- Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
- Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘
AOF重写
由于 AOF 文件会随着写操作的增加而不断增长,为了解决这个问题, Redis 提供了重写机制来对 AOF 文件进行压缩和优化
第二种是在 Redis 配置文件中设置自动重写参数,比如说 auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size,表示当 AOF 文件大小超过指定值时,自动触发重写。1
2auto-aof-rewrite-percentage 100 # 默认值100,表示当前AOF文件大小相比上次重写后大小增长了多少百分比时触发重写
auto-aof-rewrite-min-size 64mb # 默认值64MB,表示AOF文件至少要达到这个大小才会考虑重写
Redis 在收到重写指令后,会创建一个子进程,并 fork 一份与父进程完全相同的数据副本,然后遍历内存中的所有键值对,生成重建它们所需的最少命令。比如说多个 RPUSH 命令可以合并为一个带有多个参数的 RPUSH;
比如说一个键被设置后又被删除,这个键的所有操作都不会被写入新 AOF。
比如说使用 SADD key member1 member2 member3 代替多个单独的 SADD key memberX。
子进程在执行 AOF 重写的同时,主进程可以继续处理来自客户端的命令。
为了保证数据一致性,Redis 使用了 AOF 重写缓冲区机制,主进程在执行写操作时,会将命令同时写入旧的 AOF 文件和重写缓冲区。
等子进程完成重写后,会向主进程发送一个信号,主进程收到后将重写缓冲区中的命令追加到新的 AOF 文件中,然后调用操作系统的 rename,将旧的 AOF 文件替换为新的 AOF 文件。
AOF 文件存储的是什么类型的数据?
AOF 文件存储的是 Redis 服务器接收到的写命令数据,以 Redis 协议格式保存。
这种格式的特点是,每个命令以*开头,后跟参数的数量,每个参数前用$符号,后跟参数字节长度,然后是参数的实际内容。
混合持久化
混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
配置文件中设置 aof-use-rdb-preamble yes
高可用
主从复制
主从复制允许从节点维护主节点的数据副本。在这种架构中,一个主节点可以连接多个从节点,从而形成一主多从的结构。主节点负责处理写操作,从节点自动同步主节点的数据变更,并处理读请求,从而实现读写分离。
主从复制作用
第一,主节点负责处理写请求,从节点负责处理读请求,从而实现读写分离,减轻主节点压力的同时提升系统的并发能力
第二,从节点可以作为主节点的数据备份,当主节点发生故障时,可以快速将从节点提升为新的主节点,从而保证系统的高可用性。
主从复制数据不一致性
Redis 的主从复制是异步进行的,因此在主节点宕机、网络波动或复制延迟较高时会出现从节点数据不同步的情况。比如主节点写入数据后宕机,但从节点还未来得及复制,就会出现数据不一致。另一个容易被忽视的因素是主节点内存压力。当主节点内存接近上限并启用了淘汰策略时,某些键可能被自动删除,而这些删除操作如果未能及时同步,就会造成从节点保留了主节点已经不存在的数据。
主从复制不一致的解决方案
首先是网络层面的优化,理想情况下,主从节点应该部署在同一个网络区域内,避免跨区域的网络延迟。
其次是配置层面的调整,比如说适当增大复制积压缓冲区的大小和存活时间,以便从节点重连后进行增量同步而不是全量同步,以最大程度减少主从同步的延迟。
第三是引入监控和自动修复机制,定期检查主从节点的数据一致性。
比如说通过比较主从的 offset 差值判断从库是否落后。一旦超过设定阈值,就将从节点剔除,并重新进行全量同步。
主从复制原理
缓存设计
如何保证缓存和数据库的一致性
对于允许短暂不一致性的数据,可以采用旁路缓存和TTL过期机制来保证缓存和数据库的一致性。
具体读取时先查Redis,未命中再查MySQL,同时给缓存设置一个过期时间;
更新时先更新MySQL,再删除Redis. 适用于读多写少的场景,TTL 过期时间也能够保证即使更新操作失败,未能及时删除缓存,过期时间也能确保数据最终一致。
为什么要删除缓存而不是更新缓存
最初设计缓存策略时,我也考虑过直接更新缓存,但通过实践发现,删除缓存是更优的选择。
最主要的原因是在并发环境下,假设我们有两个并发的更新操作,如果采用更新缓存的策略,就可能出现这样的时序问题:
- 操作 A 和操作 B 同时发生,A 先更新 MySQL 将值改为 10,B 后更新 MySQL 将值改为 11。但在缓存更新时,可能 B 先执行将缓存设为 11,然后 A 才执行将缓存设为10。这样就会造成 MySQL 是 11 但 Redis 是 10 的不一致状态。
而采用删除策略,无论 A 和 B 谁先删除缓存,后续的读取操作都会从 MySQL 获取最新值。
另外,相对而言,删除缓存的速度比更新缓存的速度快得多。因为删除操作只是简单的 DEL 命令,而更新可能需要重新序列化整个对象再写入缓存

为什么要先更新数据库,再删除缓存
假设我们采用先删缓存再更新数据库的策略,在高并发场景下就可能出现这样的问题:
- 线程 A 要更新用户信息,先删除了缓存
- 线程 B 恰好此时要读取该用户信息,发现缓存为空,于是查询数据库,此时还是旧值
- 线程 B 将查到的旧值重新放入缓存
- 线程 A 完成数据库更新
结果就是数据库是新的值,但缓存中还是旧值。

对缓存数据库一致性要求很高,该怎么办
当业务对缓存与数据库的一致性要求很高时,比如支付系统、库存管理等场景,我会采用多种策略来保证强一致性。

第一种,引入消息队列来保证缓存最终被删除,比如说在数据库更新的事务中插入一条本地消息记录,事务提交后异步发送给 MQ 进行缓存删除。

第二种,使用Canal监听Mysql的binlog,在数据更新时,将数据变更记录到消息队列中,消费者消息监听到变更后去删除缓存.这种方案的优势是完全解耦了业务代码和缓存维护逻辑。

如果说业务比较简单,不需要上消息队列,可以通过延迟双删策略降低缓存和数据库不一致的时间窗口,在第一次删除缓存之后,过一段时间之后,再次尝试删除缓存。
最后,无论采用哪种策略,最好为缓存设置一个合理的过期时间作为最后的保障。即使所有的主动删除机制都失败了,TTL 也能确保数据最终达到一致:例如,关键数据,短TTL,不太重要数据缓存时间更长.
能确保即使出现极端情况,数据不一致的影响也是可控的
如何保证本地缓存和分布式缓存的一致性
为了减轻 Redis 的负载压力,我又追加了一层本地缓存 Caffeine

为了保证 Caffeine 和 Redis 缓存的一致性,我采用的策略是当数据更新时,通过 Redis 的 pub/sub 机制向所有应用实例发送缓存更新通知,收到通知后的实例立即更新或者删除本地缓存。
考虑到消息可能丢失,我还会引入版本号机制作为补充。每次从 Redis 获取数据时添加一个最新的版本号。从本地缓存获取数据前,先检查自己的版本号是否是最新的,如果发现版本落后,就主动从 Redis 中获取最新数据。
在项目中多个地方都要使用到二级缓存的逻辑,如何设计这一块?
将二级缓存抽象成一个统一的组件。设计一个 CacheManager 作为核心入口,提供 get、put、evict 等基本操作,执行先查本地缓存,再查分布式缓存,最后查数据库的完整流程。
本地缓存和Redis的区别
Redis 可以部署在多个节点上,支持数据分片、主从复制和集群。而本地缓存只能在单个服务器上使用。
对于读取频率极高、数据相对稳定、允许短暂不一致的数据,我优先选择本地缓存。比如系统配置信息、用户权限数据、商品分类信息等。
而对于需要实时同步、数据变化频繁、多个服务需要共享的数据,我会选择 Redis。比如用户会话信息、购物车数据、实时统计信息等。
什么是热Key
热 Key,就是指在很短时间内被频繁访问的键。比如电商大促期间爆款商品的详情信息,流量明星爆瓜时的个人资料、热门话题等,都可能成为热Key。
由于 Redis 是单线程模型,大量请求集中到同一个键会导致该 Redis 节点的 CPU 使用率飙升,响应时间变长。在 Redis 集群环境下,热Key 还会导致数据分布不均衡,某个节点承受的压力过大而其他节点相对空闲。更严重的情况是,当热Key 过期或被误删时,会引发缓存击穿问题。
如何监控热key
临时的方案可以使用 redis-cli --hotkeys 命令来监控 Redis 中的热 Key。1
redis-cli -h <address> -p <port> -a<password> — hotkey
或者在访问缓存时,在本地维护一个计数器,当某个键的访问次数在一分钟内超过设定阈值,就将其标记为热Key。
如何处理热key
最有效的解决方法是增加本地缓存,将热 Key 缓存到本地内存中,这样请求就不需要访问 Redis 了。
对于一些特别热的 Key,可以将其拆分成多个子 Key,然后随机分布到不同的 Redis 节点上。比如将 hot_product:12345 拆分成 hot_product:12345:1、hot_product:12345:2 等多个副本,读取时随机选择其中一个。
如何处理大Key
大Key 是指占用内存空间较大的缓存键,比如超过 10M 的键值对。常见的大Key 类型包括:包含大量元素的 List、Set、Hash 结构,存储大文件的 String 类型,以及包含复杂嵌套对象的 JSON 数据等。
在内存有限的情况下,可能导致 Redis 内存不足。另外,大Key 还会导致主从复制同步延迟,甚至引发网络拥塞。可以通过 redis-cli --bigkeys 命令来监控 Redis 中的大 Key。
或者编写脚本进行全量扫描,使用SCAN命令遍历所有键.
对于大 Key 问题,最根本的解决方案是拆分大 Key,将其拆分成多个小 Key 存储。比如将一个包含大量用户信息的 Hash 拆分成多个小 Hash。
另外,对于 JSON 数据,可以进行 Gzip 压缩后再存储,虽然会增加一些 CPU 开销,但在内存敏感的场景在是值得的。
缓存预热怎么做
缓存预热是指在系统启动或者特定时间点,提前将热点数据加载到缓存中,避免冷启动时大量请求直接打到数据库。会在项目启动时将热门文章提前加载到 Redis 中,在每天凌晨定时将最新的站点地图更新到 Redis中,以确保用户在第一次访问时就能获取到缓存数据,从而减轻数据库的压力。
无底洞问题是什么 如何解决
“Redis 无底洞问题”并非指 Redis 本身的漏洞,而是指在分布式缓存集群中,由于不当使用或配置失当,导致集群性能急剧下降,资源被耗尽,仿佛陷入了一个无法填满的无底洞。
无底洞问题的核心在于,随着缓存节点数量的增加,虽然总的存储容量和理论吞吐量都在增长,但是单个请求的响应时间反而变长了。
这个问题的根本原因是网络通信开销的增加。当节点数量从几十个增长到几千个时,客户端需要与更多的节点进行通信。其次就是数据分布的碎片化。随着节点增多,数据分散得更加细碎,原本可以在一个节点获取的相关数据,现在可能分散在多个节点上。
针对这个问题,可以采取以下几种解决方案:
第一,可以将同一节点的多个请求合并成一个批量请求,减少网络往返次数。
第二,可以使用一致性哈希算法来优化数据分布,减少数据迁移和重分布的开销。
Redis运维
Redis报内存不足怎么处理
Redis 报内存不足时,通常是因为 Redis 占用的物理内存已经接近或者超过了配置的最大内存限制。这时可以采取以下几种步骤来处理:
第一,使用 INFO memory 命令查看 Redis 的内存使用情况,看看是否真的达到了最大内存限制。
第二,如果服务器还有可用内存的话,修改 redis.conf 中的 maxmemory 参数,增加 Redis 的最大内存限制。比如将最大内存设置为 8GB
第三,修改 maxmemory-policy 参数来调整内存淘汰策略。比如可以选择 allkeys-lru 策略,让 Redis 自动删除最近最少使用的键
redis key过期策略有哪些
redis键值对过期删除策略:定期删除以及惰性删除.
惰性删除是最基本的策略,当客户端访问一个 key 时,Redis 会检查该 key 是否已过期,如果过期就会立即删除并返回 nil。这种策略的优点是不会有额外的 CPU 开销,只在访问 key 时才检查。但问题是如果一个过期的 key 永远不被访问,它就会一直占用内存。
于是就有了定期删除策略,Redis 会定期随机选择一些设置了过期时间的 key 进行检查,删除其中已过期的 key。这个过程默认每秒执行 10 次,每次随机选择 20 个 key 进行检查。
redis有哪些内存淘汰策略
当内存使用接近 maxmemory 限制时,Redis 会依据内存淘汰策略来决定删除哪些 key 以缓解内存压力。

常用的内存淘汰策略有八种,分别是默认的 noeviction,内存不足时不会删除任何 key,直接返回错误信息,生产环境下基本上不会使用。
然后是针对所有 key 的 allkeys-lru、allkeys-lfu 和 allkeys-random。lru 会删除最近最少使用的 key,在纯缓存场景中最常用,能自动保留热点数据;lfu 会删除访问频率最低的 key,更适合长期运行的系统;random 会随机删除一些 key,一般不推荐使用。
其次是针对设置了过期时间的 key,有 volatile-lru、volatile-lfu、volatile-ttl 和 volatile-random。
重要数据不设置过期时间,在volatile-策略下不会被淘汰,临时数据设置过期时间,可以被volatile-策略淘汰.
LRU和LFU的差别
LRU 是 Least Recently Used 的缩写,基于时间维度,淘汰最近最少访问的键。
LFU 是 Least Frequently Used 的缩写,基于次数维度,淘汰访问频率最低的键。
假设缓存中有三个数据 A、B、C,在 LRU 场景下,如果访问顺序是 A→B→C→A,那么此时的 LRU 顺序是B→C→A,如果需要淘汰,会先删除 B。
但在 LFU 场景下,如果 A 被访问了 5 次,B 被访问了 2 次,C 被访问了 1 次,那么无论最近的访问顺序如何,都会优先淘汰 C,因为它的访问频率最低
LRU 更适合有明显时间局部性的场景,比如在新闻网站中,用户更关心最新的新闻,而昨天的新闻访问量会急剧下降。这种情况下,LRU 能很好地保留用户当前关心的热点内容。
LFU 则更适合有长期访问模式的场景,更强调“热度”,比如在电商平台中,某些商品可能长期保持热销状态,即使它们的访问时间间隔较长,但由于访问频率高,LFU 会优先保留这些商品的信息
Redis发生了阻塞如何处理
Redis 发生阻塞在生产环境中是比较严重的问题,当发现 Redis 变慢时,我会先通过 monitor 命令查看当前正在执行的命令,或者使用 slowlog 命令查看慢查询日志。
大Key 是导致 Redis 阻塞的主要原因之一。比如说直接 DEL 一个包含几百万个元素的 Set,就会导致 Redis 阻塞几秒钟甚至更久。
这时候可以用 UNLINK 命令替代 DEL 来异步删除,避免阻塞主线程。1
2
redis-cli UNLINK big_key
对于非常大的集合,可以使用 SCAN 命令分批删除。1
2
3
4
5
6
7
8
9
10public void safeBatchProcess(String key) {
ScanOptions options = ScanOptions.scanOptions().count(1000).build();
Cursor<String> cursor = redisTemplate.opsForSet().scan(key, options);
while (cursor.hasNext()) {
String member = cursor.next();
// 分批处理,避免阻塞
processElement(member);
}
}
另外,当 Redis 使用的内存超过物理内存时,操作系统会将部分内存交换到磁盘,这时候会导致 Redis 响应变慢。 可以使用 free -h 检查内存的使用情况 ;确认 Redis 的 maxmemory 设置是否合理;如果发生了内存交换,立即调整 maxmemory 并清理一些不重要的数据。
Redis应用
Redis如何实现异步消息队列
Redis 实现异步消息队列是一个很实用的技术方案,最简单的方式是使用 List 配合 LPUSH 和 RPOP 命令。另外就是用 Redis 的 Pub/Sub 来实现简单的消息广播和订阅。发布者将消息发布到指定的频道,订阅该频道的客户端就能收到消息。但是这两种方式都是不可靠的,因为没有 ACK 机制所以不能保证订阅者一定能收到消息,也不支持消息持久化。
redis如何实现延迟消息队列
延时消息队列在实际业务中很常见,比如订单超时取消、定时提醒等场景。Redis 虽然不是专业的消息队列,但可以很好地实现延时队列功能。
核心思路是利用 ZSet 的有序特性,将消息作为 member,把消息的执行时间作为 score。这样消息就会按照执行时间自动排序,我们只需要定期扫描当前时间之前的消息进行处理就可以了
具体实现上,我会在生产者发送延时消息时,计算消息应该执行的时间戳,然后用 ZADD 命令将消息添加到 ZSet 中。1
ZADD delay_queue 1617024000 task1
消费者通过定时任务,使用 ZRANGEBYSCORE 命令获取当前时间之前的所有消息。1
ZREMRANGEBYSCORE delay_queue -inf 1617024000
处理完成后再用 ZREM 删除消息。1
ZREM delay_queue task1
可以用这种方式实现文章定时发布的功能。作者在发布文章时,可以选择一个未来的时间节点,比如说 30 分钟后,系统就会向延时队列发送一条延时消息,然后定时任务就会在 30 分钟后将这条消息从延时队列中取出并发布文章。
Redis支持事务吗
Redis 支持简单的事务,可以将 multi、exec、discard 和 watch 命令打包,然后一次性的按顺序执行。
基本流程是用 multi 开启事务,然后执行一系列命令,最后用 exec 提交。这些命令会被放入队列,在 exec 时批量执行。当客户端处于非事务状态时,所有发送给 Redis 服务的命令都会立即执行;但当客户端进入事务状态之后,这些命令会被放入一个事务队列中,然后立即返回 QUEUED,表示命令已入队。
当 exec 命令执行时,Redis 会将事务队列中的所有命令按先进先出的顺序执行。当事务队列里的命令全部执行完毕后,Redis 会返回一个数组,包含每个命令的执行结果。discard 命令用于取消一个事务,它会清空事务队列并退出事务状态。watch 命令用于监视一个或者多个 key,如果这个 key 在事务执行之前 被其他命令改动,那么事务将会被打断。但 Redis 的事务与 MySQL 的有很大不同,它并不支持回滚,也不支持隔离级别。
Redis事务的原理
当执行 MULTI 命令时,Redis 会给这个客户端打一个事务的标记,表示这个客户端后面发送的命令不会被立即执行,而是被放到一个队列里排队等着。当 Redis 收到 EXEC 命令时,它会把队列里的命令一个个拿出来执行。因为 Redis 是单线程的,所以这个过程不会被其他命令打断,这就保证了Redis 事务的原子性。
当执行 WATCH 命令时,Redis 会将 key 添加到全局监视字典中;只要这些 key 在 EXEC 前被其他客户端修改,Redis 就会给相关客户端打上脏标记,EXEC 时发现事务已被干扰就会直接取消整个事务。DISCARD 做的事情很简单直接,首先检查客户端是否真的在事务状态,如果不在就报错;如果在事务状态,就清空事务队列并退出事务状态。
Redis 事务不支持回滚,一旦 EXEC 命令被调用,所有命令都会被执行,即使有些命令可能执行失败。
Redis 的核心设计理念是简单、高效,而不是完整的 ACID 特性。而实现回滚需要在执行过程中保存大量的状态信息,并在发生错误时逆向执行命令以恢复原始状态。这会增加 Redis 的复杂性和性能开销。
Redis的事务满足原子性吗 要怎么改进
Redis 的事务不能满足标准的原子性,因为它不支持事务回滚,也就是说,假如某个命令执行失败,整个事务并不会自动回滚到初始状态。
可以使用 Lua 脚本来替代事务,脚本运行期间,Redis 不会处理其他命令,并且我们可以在脚本中处理整个业务逻辑,包括条件检查和错误处理,保证要么执行成功,要么保持最初的状态,不会出现一个命令执行失败、其他命令执行成功的情况。
Redis事务的ACID特性
单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务在执行过程中如果某个命令失败了,其他命令还是会继续执行,不会回滚。
一致性指的是,如果数据在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据也应该是一致的。但 Redis 事务并不保证一致性,因为如果事务中的某个命令失败了,其他命令仍然会执行,就会出现数据不一致的情况。
Redis 是单线程执行事务的,并且不会中断,直到执行完所有事务队列中的命令为止。因此,我认为 Redis 的事务具有隔离性的特征。
Redis 事务的持久性完全依赖于 Redis 本身的持久化机制,如果开启了 AOF,那么事务中的命令会作为一个整体记录到 AOF 文件中,当然也要看 AOF 的 fsync 策略。如果只开启了 RDB,事务中的命令可能会在下次快照前丢失。如果两个都没有开启,肯定是不满足持久性的。
Lua脚本操作Redis
Lua 脚本是处理 Redis 复杂操作的首选方案,比如说原子扣减库存、分布式锁、限流等业务场景,都可以通过 Lua 脚本来实现。
在秒杀场景下,可以用 Lua 脚本把所有检查逻辑都写在一起:先看库存够不够,再看用户有没有买过,所有条件都满足才扣减库存。因为整个脚本是原子执行的,Redis 在执行期间不会处理其他命令,所以可以彻底解决超卖问题。1
2
3
4
5
6
7
8
9
10
11String luaScript =
"local stock = redis.call('GET', KEYS[1]) " +
"if not stock or tonumber(stock) < tonumber(ARGV[2]) then " +
" return -1 " + // 库存不足
"end " +
"if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then " +
" return -2 " + // 重复购买
"end " +
"redis.call('DECRBY', KEYS[1], ARGV[2]) " +
"redis.call('SADD', KEYS[2], ARGV[1]) " +
"return 1";
在分布式锁场景下,一开始用的 SETNX 命令来实现,结果发现如果程序异常退出,锁就死掉了。后来加了过期时间,但又发现可能误删其他线程的锁。最后还是用 Lua 脚本彻底解决了这个问题,确保只有锁的持有者才能释放锁。1
2
3
4
5
6
7// 解锁脚本特别重要,必须验证是自己的锁才能删
private final String UNLOCK_SCRIPT =
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else " +
" return 0 " +
"end";
甚至还可以用 Lua脚本实现滑动窗口限流器,一次性完成过期数据清理、计数检查、新记录添加三个操作,而且完全原子化。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 滑动窗口限流,逻辑清晰,性能还好
String luaScript =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local window = tonumber(ARGV[2]) " +
"local limit = tonumber(ARGV[3]) " +
// 先清理过期记录
"redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " +
// 检查当前请求数
"local current = redis.call('ZCARD', key) " +
"if current < limit then " +
" redis.call('ZADD', key, now, now) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Redis的管道pipeline
Pipeline 允许客户端一次性向 Redis 服务器发送多个命令,而不必等待一个命令响应后才能发送下一个。Redis 服务器会按照命令的顺序依次执行,并将所有结果打包返回给客户端。
正常情况下,每执行一个 Redis 命令都需要一次网络往返:发送命令 -> 等待响应 -> 发送下一个命令。
如果大量请求依次发送,网络延迟会显著增加请求的总执行时间,假如一次 RTT 的时间是 1 毫秒,3 个就是 3 毫秒。有了 Pipeline 后,可以一次性发送 3 个命令,总时间就只需要 1 毫秒。
当然了,Pipeline 不是越大越好,太大会占用过多内存,通常建议每个 Pipeline 包含 1000 到 5000 个命令。可以根据实际情况调整。
需要批量插入、更新或删除数据,或者需要执行大量相似的命令时。比如:系统启动时的缓存预热 -> 批量加载热点数据;比如统计数据的批量更新;比如大批量数据的导入导出;比如批量删除过期或无效的缓存。
pipeline的底层原理
缓冲的思想. 可以在RedisClient 类中封装了一个 PipelineAction 内部类,用来缓存命令。
add 方法将命令包装成 Runnable 对象,放入 List 中。当执行 execute 方法时,再调用 RedisTemplate 的 executePipelined 方法开启管道模式将多个命令发送到 Redis 服务端。
Redis 服务端从输入缓冲区读到命令后,会按照 RESP 协议进行命令拆解,再依次执行这些命令。执行结果会写入到输出缓冲区,最后再将所有结果一次性返回给客户端。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@Service
public class RedisBatchService {
public void batchInsertUsers(List<User> users) {
// 不用Pipeline的错误做法 - 很慢
// for (User user : users) {
// redisTemplate.opsForValue().set("user:" + user.getId(), user);
// }
// 使用Pipeline的正确做法
redisTemplate.executePipelined(new RedisCallback<Object>() {
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
for (User user : users) {
String key = "user:" + user.getId();
byte[] keyBytes = key.getBytes();
byte[] valueBytes = serialize(user);
connection.set(keyBytes, valueBytes);
}
return null; // Pipeline不需要返回值
}
});
}
}
Redis实现分布式锁
分布式锁是一种用于控制多个不同进程在分布式系统中访问共享资源的锁机制。它能确保在同一时刻,只有一个节点可以对资源进行访问,从而避免分布式场景下的并发问题。
