Redis八股分析

Redis

1.多级缓存数据一致性与失败回滚

当被问及如何保证Redis和本地缓存更新的原子性,以及在更新失败时如何回滚,你的回答提到了不甚准确的“编程式事务”,并最终倾向于人工处理。

方案1:引入消息队列(MQ)进行可靠的异步处理

  1. 修改架构:Canal不再直接调用消费逻辑,而是将解析后的binlog事件作为消息发送到MQ的一个Topic中。
  2. 消费者逻辑:消费者服务从MQ拉取消息。其处理逻辑是:先失效Redis缓存,再发布一个广播消息(如通过Redis Pub/Sub)通知所有应用实例失效本地Caffeine缓存。
  3. 失败处理:只有当所有步骤成功后,消费者才向MQ发送ACK。如果处理过程中任何一步失败(如Redis连接超时),消费者不发送ACK。MQ会在超时后将该消息重新投递给其他消费者,实现自动重试。

方案2 死信队列

  1. 在Canal的消费者逻辑中,使用Spring Retry等框架对缓存失效操作进行封装。
  2. 配置重试策略,例如重试3次,每次间隔采用指数退避(如1s, 2s, 4s),避免在故障期间频繁冲击下游服务。
  3. 配置一个RecoveryCallback。当所有重试都失败后,将这条失败的binlog事件(包含表名、主键、操作类型等信息)发送到一个专门的死信队列(Dead Letter Queue)或记录到数据库的失败任务表中。
  4. 部署一个独立的监控程序或定时任务,消费DLQ中的消息,并发送告警(邮件、短信、钉钉)。

如果重试逻辑设计不当,可能会在短时间内放大故障。死信队列需要有完善的监控,否则会成为被遗忘的角落。

方案3 先更新缓存,再更新数据库”的策略

  1. 写请求:先更新(或失效)Redis缓存,然后更新数据库。
  2. 为了解决并发更新导致的不一致问题,可以引入“延时双删”:先删缓存 -> 更新数据库 -> 延迟一段时间(如500ms)后再次删除缓存。
  3. 本地Caffeine缓存仍然可以通过监听Redis的key失效事件(Keyspace Notifications)或消息广播来同步失效。

非常不推荐。延时双删的延迟时间很难确定,无法100%保证一致性。代码侵入性强,业务逻辑与缓存逻辑耦合严重,维护困难。

2.什么情况下,就是两个线程会持有同一把锁

两个不同的线程在同一时刻是不可能持有同一把锁的,这是锁的互斥性基本原则所保证的。如果出现了这种情况,那一定是锁的实现出了严重的问题。

您这个问题可能是在考察一个非常重要的特性——锁的可重入性。可重入性指的是同一个线程可以多次成功获取同一把锁,而不会自己把自己锁死。在释放锁时,也需要释放相应次数后,锁才会被真正释放。”

比如:在一个复杂的业务方法A中,它获取了锁。然后它又调用了另一个方法B,而方法B也需要获取同一个锁。如果没有可重入性,那么在方法B中,当前线程会因为无法获取一个已经被自己持有的锁而陷入死锁。

实现:Redisson巧妙地使用了Redis的Hash数据结构来实现。

  • 当一个线程第一次获取锁时,它会在Redis中创建一个Hash。这个Hash的Key是锁的名称(例如myLock)。
  • 这个Hash结构内部会存储两个关键信息:
    • 一个field存储持有锁的线程标识(例如,UUID + ThreadId)。
    • 另一个field存储一个计数器,表示该线程重入的次数,初始值为1。
  • 当同一个线程再次尝试获取这把锁时,Redisson会检查Hash中存储的线程标识。如果与当前线程标识匹配,它就不会阻塞,而是直接将计数器的值加1,表示又重入了一次。
  • 当线程释放锁时,它会去将计数器减1。只有当计数器的值减到0时,Redisson才会真正地从Redis中删除这个Hash(即释放锁),这样其他线程才有机会获取。

3.如果Canal挂了怎么办?或者Canal到消费端的链路出现长时间中断,会发生什么?有什么容灾方案吗?

您提的这个问题非常关键,它涉及到整个数据同步链路的高可用性

  1. Canal自身的高可用:首先,Canal自身是可以部署成高可用集群的。通过Zookeeper进行集群管理和主备选举,当主节点宕机时,备用节点可以自动接管,从而保证了数据订阅服务的连续性。
  2. 链路中断的影响:如果Canal到消费端的链路中断,确实会导致缓存与数据库在中断期间的数据不一致窗口期变长。新写入的数据无法触发缓存失效,用户可能会在一段时间内读到旧的缓存数据。
  3. 我们的容灾与补偿策略
    • 监控与告警:我们必须对Canal的消费位点(Position)与MySQL主库的最新binlog位点之间的延迟做严格的监控。一旦延迟超过阈值(比如1分钟),就立即触发高级别告警,通知SRE和开发团队介入。
    • 设置合理的缓存TTL:即使同步链路中断,我们缓存中的数据也不是永久有效的。通过为所有缓存设置一个合理的兜底过期时间(TTL),比如1小时,可以保证即使在最坏的情况下,数据不一致的时间也不会无限延长。这是一种自愈机制
    • 手动全量/增量校准:对于极端重要的数据,我们会准备一个手动触发的数据校准脚本。当链路长时间中断并恢复后,可以运行这个脚本,根据时间戳或版本号,主动查询数据库,强制刷新Redis中的核心数据,确保最终一致性。”

4.你提到用Redis的Pub/Sub来广播失效Caffeine本地缓存。

Pub/Sub是‘fire-and-forget’(即发即忘)模式,不保证消息必达。如果某个应用实例因为网络抖动没收到失效消息,怎么办?

您观察得非常仔细,Pub/Sub确实存在消息丢失的风险。对于这个问题,我们有分层级的解决方案

  1. 接受短暂不一致:对于大部分业务场景,单台服务器上短暂的本地缓存不一致是可以接受的。因为流量通常会通过负载均衡打到多台服务器上,只有一小部分用户请求会命中这台机器的旧缓存,且Caffeine本身也有过期机制,影响是可控的。
  2. 引入更可靠的消息总线:如果业务对一致性要求极高,我们会放弃轻量级的Pub/Sub,转而使用更可靠的消息中间件(如RocketMQ)的广播消费模式。每个应用实例都作为一个消费者组内的广播消费者,订阅失效通知。MQ的ACK机制可以保证每个实例都可靠地收到失效消息。
  3. 版本号机制:我们可以在缓存的对象中增加一个版本号或时间戳字段。当应用从缓存中获取到数据后,可以(在某些关键操作前)与数据库中的版本号进行一次快速比对。如果发现缓存版本落后,就主动失效本地缓存并重新加载。这是一种主动校验的补偿机制。”

5.缓存三问题

布隆过滤器和缓存空值,这两种方案在你的项目中,你会如何选择?它们各自有什么优缺点和需要注意的地方?

方案一:缓存空值(Cache Null Values)

  • 优点:

    • 实现简单:逻辑清晰,开发和维护成本极低。
    • 效果直接:能100%拦截住对同一个不存在的key的重复攻击。
  • 缺点与注意事项:

    • 消耗额外的缓存空间:如果被恶意攻击,攻击者不断变换不存在的key来查询,会导致Redis中存储大量的空值key,造成内存浪费。
    • 数据一致性问题:如果这个之前不存在的数据,后来又在数据库中被创建了(例如,一个新用户注册了),缓存中的空值需要有一种机制被及时地更新或失效,否则会导致用户刚注册完却查不到自己的信息。

    适用于不存在的key的集合相对固定,或者重复查询率高的场景。例如,查询一个已经下架的商品

方案二:布隆过滤器(Bloom Filter)

  • 优点:
    • 空间效率极高:它使用位图(bitmap)来存储数据,占用的内存空间远小于缓存空值方案,非常适合处理海量数据。
  • 缺点与注意事项:
    • 存在误判率(False Positive):布隆过滤器判断“不存在”是100%准确的,但判断“存在”时,有一定概率会把一个不存在的key误判为存在。这意味着它无法完全拦截所有穿透请求,会有一小部分漏网之鱼打到数据库。
    • 无法删除元素:标准的布隆过滤器不支持删除操作。如果数据需要频繁地增删,就需要使用Counting Bloom Filter等变种,实现更复杂。
    • 初始化和重建成本:需要在系统启动时,将全量数据加载到布隆过滤器中,这个过程可能比较耗时。当数据发生变化时,也需要有机制来同步更新过滤器。
  • 适用场景:适用于数据量巨大,但数据相对稳定,且对误判率有一定容忍度的场景。例如,防止恶意用户用随机生成的ID来攻击用户查询接口。

6.用户在10分钟之内连续输错三次密码,就禁止其登录”。如果使用 Redis,你会选择哪种数据结构来实现

方案1:使用String

Redis的INCR命令是原子性的,可以保证在并发环境下计数的准确性。EXPIRE命令可以为一个key设置生存时间(TTL),完美地契合了“10分钟之内”这个时间窗口的需求。

  1. 定义Key:为每个用户的登录失败计数定义一个清晰的Key,例如:login:fail:count:{userId}
  2. 登录失败逻辑:当用户登录失败时,执行以下操作:
    • 对该用户的Key执行INCR命令,获取增长后的计数值:count = redis.incr("login:fail:count:{userId}")
    • 判断是否是第一次失败:如果count等于1,说明这是10分钟窗口内的第一次失败。此时,必须为这个Key设置过期时间:redis.expire("login:fail:count:{userId}", 600) (600秒 = 10分钟)。
    • 检查是否达到阈值:判断count是否大于等于3。如果是,则触发锁定用户的逻辑(例如,在数据库中更新用户状态,或在另一个Redis Key中设置一个锁定标记)。
  3. 登录成功逻辑:当用户登录成功时,应该立即删除这个计数Key:redis.del("login:fail:count:{userId}"),以清除之前的失败记录。

问题:

  • 存在一个微小的竞态条件(Race Condition):在INCREXPIRE两个命令之间,如果服务器恰好宕机或重启,可能会导致一个计数Key被创建但没有设置过期时间,从而变成一个永久的计数器。虽然概率极低,但在高并发系统中仍需考虑。
  • 解决方案:可以使用Lua脚本INCREXPIRE两个操作打包成一个原子操作,或者使用一条Redis命令完成

方案2:灵活精确 - List 作为失败记录队列

Redis的List是一个双向链表,可以作为队列使用。通过LPUSH在队头插入元素,LTRIM修剪队列长度,可以非常高效地维护一个固定大小的事件窗口。

  1. 定义Keylogin:fail:log:{userId}
  2. 登录失败逻辑:
    • 获取当前时间戳(秒或毫秒),并将其作为元素LPUSH到List的头部:redis.lpush("login:fail:log:{userId}", System.currentTimeMillis())
    • 检查当前失败次数:获取List的长度llen
    • 如果llen大于等于3,说明已经发生了至少3次失败。此时,获取List中第3个元素(即最早的那次失败记录,索引为2):third_attempt_time = redis.lindex("login:fail:log:{userId}", 2)
    • 判断时间窗口:计算当前时间与third_attempt_time的时间差。如果差值小于10分钟,则说明在10分钟内发生了3次失败,触发锁定逻辑。
  3. 队列维护:为了防止List无限增长,可以在每次LPUSH后,使用LTRIM命令只保留最近的3条记录:redis.ltrim("login:fail:log:{userId}", 0, 2)。同时,为整个Key设置一个比10分钟稍长的过期时间,如11分钟,用于自动清理冷数据。
  4. 登录成功逻辑:同方案一,DEL掉对应的Key。
  • 实现了精确的时间窗口判断。
  • 内存占用非常小,因为每个用户的Key最多只存储3个时间戳。

方案三:功能强大 - ZSET (Sorted Set) 实现滑动时间窗口

Redis的ZSET是一个有序集合,每个成员都关联一个score。我们可以用score来存储事件发生的时间戳,利用ZSET按分数范围查询和删除的特性,完美地实现滑动时间窗口

  1. 定义Keylogin:fail:zset:{userId}
  2. 登录失败逻辑:
    • 获取当前时间戳now
    • 为了防止成员重复,可以给每个成员一个唯一的值,例如now + ":" + Math.random()
    • 将新的失败记录添加到ZSET中,scoremember都使用时间戳(或score是时间戳,member是唯一ID):redis.zadd("login:fail:zset:{userId}", now, now)
    • 清理过期记录:移除所有10分钟之前的记录,这是一个非常关键的步骤,保证了窗口的滑动:redis.zremrangebyscore("login:fail:zset:{userId}", 0, now - 600000) (假设now是毫秒)。
    • 统计窗口内次数:获取当前ZSET中的成员数量:count = redis.zcard("login:fail:zset:{userId}")
    • 检查阈值:如果count大于等于3,触发锁定逻辑。
  3. 登录成功逻辑:同方案一,DEL掉对应的Key。

7.Redis持久化

RDB 是“快照”模式,AOF 是“指令日志”模式,并理解了它们都是为了解决 Redis 宕机后的数据恢复问题。

你提到了 RDB 文件小、恢复快,但可能丢失数据;AOF 文件大、恢复慢,但数据更完整。

这是一个非常关键的知识点。当 RDB 和 AOF 文件同时存在时,Redis 会优先选择 AOF 文件来恢复数据。

  • 为什么? 因为 AOF 文件通常记录的数据比 RDB 文件更完整、更新。AOF 的默认策略是每秒写一次盘,而 RDB 默认是几分钟甚至更久才生成一次快照。为了尽可能少地丢失数据,Redis 的设计者选择了优先使用数据更全的 AOF。

AOF 重写(AOF Rewrite): 你提到了 AOF 文件会很大,这是一个很重要的缺点。但你没有提到解决这个问题的关键机制——AOF 重写。Redis 会在后台定期地对 AOF 文件进行重写,将多条冗余的命令(比如对一个 key 多次 set)合并成一条最终的命令,从而大大压缩 AOF 文件的大小。这个机制是 AOF 能够被长期使用的重要保障。

RDB 的触发方式: RDB 是“一段时间触发一次”,可以更具体地说明其触发方式,主要有:

  • save 命令: 同步阻塞式保存,会阻塞主线程,生产环境禁用。
  • bgsave 命令: 异步非阻塞式保存,Redis 会 fork 一个子进程来执行快照,这是我们手动执行或配置自动执行的主要方式。
  • 配置文件自动触发: 比如 save 900 1 (900秒内有1次写入)、save 300 10 (300秒内有10次写入)等。

    “RDB-AOF 模式”,这个概念是对的,它叫混合持久化 (Mixed Persistence)。但它的工作方式可以描述得更清晰:当触发 AOF 重写时,Redis 不再简单地写入指令,而是将重写那一刻的内存数据,以 RDB 的格式写入到新的 AOF 文件的开头,然后再将重写期间产生的增量命令,以 AOF 格式追加到文件末尾。这样做的好处是,重启恢复时,可以先像 RDB 一样快速加载内存快照,然后再重放增量命令,兼顾了 RDB 的恢复速度和 AOF 的数据完整性

8.Redis底层数据结构

ziplist:

它不是一个真正的列表,而是一块连续的内存区域。这块内存中,将多个数据项(entry)紧凑地排列在一起,从而极大地节省内存。每个entry包含三个部分:previous_entry_length(前一个节点的长度)、encoding(当前节点内容的编码方式和长度)、content(实际内容)。

  • 极致的内存效率:由于是连续内存,没有指针开销,内存利用率极高。
  • 但是有连锁更新的问题,由于每个节点都记录了前一个节点的长度,当我们在一个ziplist中间插入或删除了一个元素,如果这个元素的大小发生了变化(比如从一个小整数变成一个长字符串),就可能导致其后所有节点previous_entry_length字段都需要被级联修改。

listpack:

ziplist类似,也是一块连续的内存区域,用于紧凑地存储数据项。listpack的每个entry不再记录前一个节点的长度。取而代之的是,它记录了当前节点的总长度encoding字段中包含了长度信息)。当需要从后向前遍历时,它会先读取当前节点的前一个节点尾部,那里记录了那个节点的总长度,然后再跳到那个节点的起始位置。

依然是连续内存,内存利用率很高,但解决了连锁更新的问题。成为小数据量HashZset的底层实现。

skiplist:

Zset(有序集合)需要一种既能高效查找又能高效增删的数据结构。平衡树(如红黑树)实现复杂,而skiplist是一种概率性的、实现相对简单且性能媲美平衡树的数据结构。

从最高层的链表开始,向右查找,直到找到一个大于等于目标值的节点的前驱。然后从这个前驱节点下降一层,继续向右查找。重复此过程,直到到达最底层的链表,最终找到目标元素。

底层是链表,可以方便地进行范围遍历。增删改查效率都是O(log N)。

9.渐进式hash

“渐进式哈希”(Incremental Hashing),也常被称为“渐进式 Rehash”,它是一种优化哈希表(Hash Table)在扩容或缩容时数据迁移过程的技术

它解决了传统哈希表在扩容时,因需要一次性迁移所有数据而导致的“服务阻塞”或“卡顿”(Stop-the-World)问题。

渐进式哈希巧妙地将这个集中的、一次性的迁移任务分摊到多次操作中去完成。Redis的Rehash机制是渐进式哈希最经典的实现:

  1. 准备阶段: 当触发扩容时,Redis会为字典(dict)分配一个新的、更大的哈希表(内部称为ht[1]),而旧的哈希表(ht[0])仍然保留。此时,字典同时持有新旧两个哈希表。
  2. 迁移阶段(“渐进式”的体现): 数据迁移不是一次性完成的,而是通过两种方式分批、逐步进行:
    • 被动迁移 (Passive Rehash): 在Rehash期间,每当有客户端对字典进行增、删、改、查操作时,除了完成指定的操作外,Redis还会顺带将被操作的键所在的整个哈希桶(bucket)中的所有键值对,从旧表(ht[0])迁移到新表(ht[1])。这相当于把迁移的成本分摊到了每一次客户端请求中。
    • 主动迁移 (Active Rehash): 为了防止字典在长期没有被访问的情况下Rehash过程一直无法完成,Redis有一个后台定时任务(每秒执行10次)。这个任务会主动地、每次只花费1毫秒的时间,从旧表(ht[0])中迁移一部分数据到新表(ht[1]),确保即使在空闲时期,Rehash过程也能稳步推进。
  3. 服务期间的访问: 在整个Rehash过程中,字典的读写操作会同时兼顾新旧两个哈希表:
    • 查询/删除/更新: 会先在旧表(ht[0])中查找,如果找不到,再去新表(ht[1])中查找。
    • 新增: 只会添加到新表(ht[1])中。这保证了旧表的数据只会减少,不会增加,最终一定能迁移完毕。
  4. 完成阶段: 当旧表(ht[0])中的所有数据都迁移到新表(ht[1])后,Rehash过程结束。此时会释放旧表的内存,并将新表设置为字典的默认哈希表(ht[0] = ht[1]),为下一次Rehash做准备。

渐进式哈希通过将庞大的数据迁移任务“化整为零”,均摊到每一次的日常操作和后台的少量定时任务中,从而避免了集中的、长时间的计算。它以一种平滑、对用户几乎无感知的方式完成了哈希表的扩容,极大地保证了系统(如Redis)的高可用性响应速度