前言

感觉自己实习期间的工作没有很好地和现在大厂的技术栈结合起来

所以让 DeepSeek 融合了一个黑马点评进去

针对这份简历,同时让它问了几个面试问题,比较有深度,记录下来

内容

面试官: 很好,你提到项目中大量使用了 Redis 进行优化。我们深入聊聊 Redis 这块:

Session 管理优化

Q1.你具体是如何设计 Redis 存储 Session 的? 为什么选择这种数据结构(比如 String vs Hash)?

A1. 我在项目中主要是使用的 Hash 结构,如: (userId, userName, role, createdTime, lastAccessedTime, currentDecice...)

  • 因为我们的项目中有需要频繁增删这种无需持久化的数据。对于我们的 SessionKey 命名,是 session:{sessionId},这里的 sessionId 是用 UUid 生成的随机 32 位 ID。对于过期时间,我们设置的是 3600 秒,也就是 1 小时;每次更新我们首先会去更新 lastAccessedTime,然后重新 Expire 一下,也是 3600 秒。

  • 对于 Hash 结构,它可以用 HINCRBY(修改数字) HSET(修改字段) 等命令来源地修改单个字段,而无需读取整个对象。

  • String 结构我们还需要首先读取整个对象,然后反序列化,修改,再序列化,最后写回这一系列操作。

  • 采用 Hash 结构而非 String 结构能够大大地减少网络传输量和服务器负载


Q2.需要考虑哪些安全性或过期问题?

A2. 我分成过期策略安全策略两方面回答:

  • 对于过期策略:在常见的过期场景下有一些问题,比如用户故意保持活跃,或者登陆后使用 Session 固定攻击

    • 那么对于永不过期的活跃用户,我们应设置绝对过期时间:即通过 createTime 和当前时间进行比对,如果超过 3 天就要求重新登录。
    • 对于一些敏感操作,我们应当要求重新登录,如修改密码,也就是定期安全验证
    • 与此同时也要监控 Session ,定期扫描存活时间,超过阈值则清理。

    通过这些手段能够有效控制 SessionId 不轮换导致的固定攻击


  • 对于安全策略:分为 SessionId 的安全和 Session 存储的数据安全
    1. 对于 SessionId 的安全:
      • 首先要强制开启 Https 防止 SessionId 被劫持;
      • 其次对于 Cookie 我们应该做如下配置:
        • Httponly:防止 JS 用 document.cookit 访问 cookie;
        • Secure:仅在 Https 连接时发送 Cookie;
        • SameSite=Strict/Lax:提供 CSRF 防护,Strict 最安全,但可能影响跨站跳转的用户体验;Lax 则是较好的平衡。
    2. 对于 Session 数据安全性:
      • 首先一定不能存储非常敏感的信息,比如卡号、密码等
      • 对于 userId、role 等敏感信息需要分两方面考虑
        • 设置网络隔离:redis 部署在内网,只能够内网访问到它,不开放公网接口
        • 设置访问控制
          • 设置强密码即设置 requirePass,阻止任意客户端都能连接 Redis 的情况,访问需要携带 Auth password
          • 设置 IP 白名单,在防火墙、安全组配置可访问 Redis 端口的源 IP 地址,最好 redis 默认端口也要更换
          • Redis 6+ 引入了 ACL,它可以为不同的应用或服务创建独立用户,用户的命令权限、访问权限、读写权限都可以更加细粒度的控制。

热点数据缓存

你缓存了学习资源元数据和用户状态。

Q1. 当这些数据在后台被更新时(比如老师修改了资源信息),如何保证缓存与数据库的一致性? 你采用了什么策略?(旁路缓存?读写穿透?)

A1. 我们使用的是旁路缓存策略来保证缓存与数据库一致性:

  • 在读数据时,cache 命中就返回,未命中就从 db 中读取返回,随后将数据写入 cache 中
  • 在写数据时先更新 db,再删除 cache

于此同时,我们使用 RocketMQ存储 DB 更新成功后,异步提交的删除 Cache 操作消息,由独立的消费者监听并执行删除 Cache 操作,于此同时保障消息幂等性,以此应对删除 Cache 可能失败的情况。

Q2. 为什么是 “更新 DB + 删除 Cache” 而不是 “更新 DB + 更新 Cache”?

A2. 如果有两个请求都需要更新同一片数据,如果不施加分布式锁,可能出现更新顺序出错的问题。

  • 对于 先更新 db,再删除 cache 这种方法,首先是简单,其次是能够保障最终一致性
  • 当然它牺牲了读数据的性能,因为直接删除 Cache 会造成 Cache miss,增加了 db 的负担。

Q3. 如何应对 “先删 Cache,后更新 DB” 的失效问题?

A3. 一般使用延迟双删的解决方案,即删除缓存,然后修改数据库,然后延时一段时间(500ms)再删缓存。


Q4. Write DB -> Delete Cache 就万无一失吗?

A5. 当然不是,读写并发时可能出现读数据在 Cache miss 情况下获取数据后阻塞,写数据完成 db 更新后,读数据重建 Cache 导致不一致的问题。


Q5. 删除 Cache 失败怎么办? 如何保证删除操作最终成功?在你们的系统中,谁负责执行 “更新 DB 后删除 Cache” 的操作?应用服务在同一个事务/线程里同步执行吗?有什么风险?更好的做法?

A5. 我们使用 RocketMQ 来存储 DB 更新成功后,异步提交的删除 Cache 操作消息,由独立的消费者监听并执行删除 Cache 操作,于此同时保障消息幂等性,以此应对删除 Cache 可能失败的情况。

  • 如果放在同一个事务中执行,会增加请求延迟,如果 Cache 响应慢或者失效,会阻塞线程和引发事务回滚。

Q6. 缓存预热与击穿: 对于极热的热点数据 (如爆款课程元数据),在 Cache 被删除或失效瞬间,大量请求可能同时穿透到 DB。你们是如何预防的?

A6. 对于缓存击穿问题,我们采用了互斥锁+逻辑过期+逻辑删除的策略,即通过获取 Redis SETNX 的方式,只允许单个线程重建缓存,其余未能获取到锁的线程则返回过期数据。

  • 对于逻辑过期,即在 value 中添加一个 expire 字段,在读数据时检查是否过期;
  • 对于逻辑删除,我们使用了 Set 数据结构,将需要被删除的数据放入 Set 中,读数据的时候检查是否被删除。

布隆过滤器

你提到用 Redission 的布隆过滤器缓解缓存穿透。

Q1. 布隆过滤器的原理是什么?它的优缺点是什么?(尤其要关注误判率和不可删除)

A1. 布隆过滤器是用来缓解缓存穿透问题的工具,通过使用多个 hash 函数对 id 进行计算,以及使用 bitmap 记录计算值的 01 分布。如果某个 id 通过 hash 计算得到多个值中的某个在 bitmap 中为 0,则这个 id 就不存在。

  • 布隆过滤器很大的优点就是内存占用较少,仅需维护 bitmap,不像缓存空数据一样需要记录冗余 Key。
  • 当然布隆过滤器也有缺点:实现起来比较复杂;存在误判问题,在计算 hash 时,不同的 id 也有可能映射到相同的多个 hash 值中。这一点是可控的,即将 bitmap 容量提高,那么误判率就会下降;以及它的不能扩容不可删除特性,存入了 id 映射后就不能删除,因为不同元素的哈希位置可能重叠:删除元素 A 可能导致元素 B 被误判为不存在。

Q2. 布隆过滤器原理的数学本质?

A2. 误判率公式, p = (1 - e ^ (-kn/m)) ^ k

n = 元素数量,m = bit 数组长度,k = 哈希函数个数

优化方向:m / n (每个元素分配 bit 个数) 增大时,p 指数级下降
最优:k = (m/n) ln2


Q3. 在你的场景里,缓存穿透具体是指什么请求导致的?是如何构造的?

A3. 一个比较常见的例子是是用户在直播结束之后没有退出,刷新已结束的直播页面、在已结束的直播间中做各种操作等。像这种请求都会携带直播 ID,许多基于该 ID 缓存的状态变量在直播结束后就会从缓存中删除,从而造成了缓存穿透。再一个就是请求不存在的 id,这种用缓存空值方法就能比较容易解决。

  • 我的键命名方法是 业务名 + 数据名 + id,比如刚才说到的直播 ID,就是 live:room:uuid,这个 UUID 对应 sql 数据库直播表中存储的 id

Q4. 为什么选择 Redission 而非 Guava?初始化参数?过滤器更新策略?是如何集成到缓存流程中的?

A4. Guava 的实现是位于服务器本地的单机布隆过滤器,集群环境下需要独立维护。而 Redission 是基于 Redis 实现的,整个集群共享同一过滤器。由于项目已经是分布式架构了,所以我们选择了 Redission 这种全局一致防护的实现方式。

  • 对于 Redission 布隆过滤器初始化,它需要设定预期元素数量p 误判率,对于预期元素数量,我们设定为了历史直播峰值房间数 * 5,通过权衡内存压力与 DB 压力,我们误判率选择 1%。

  • 对于过滤器更新策略,新增直播时添加到过滤器中,结束直播时就不删除过滤器中的内容,因为有之前提到的逻辑删除保底;当然这样做会导致旧的 key 越来越多,我们就需要定期重建过滤器。我们每三天会用有效 roomID 全量重建

实时状态同步

Q1. 你使用了 Redis 的哪些数据结构和特性来实现?

A1. 基于已有业务,我们使用了 String 计数直播点赞数;使用了 Hash 结构来实现直播间中需要实时更新的各种数据;如直播状态、直播间人数等;使用了 Sorted Set 实现房间总人数、举手、直播间发消息功能。使用了 Pub/Sub 来实现无需持久化实时消息广播,如加入直播间消息、实时弹幕(直播消息)、写作文档光标位置。


Q2. Pub/Sub 在这里扮演什么角色?

A2. 考虑 Pub/Sub 局限性

  • 由于 Redis 是基于内存的,而且 Pub/Sub 也是保存到了内存的字典数据结构中,所以发布的消息不会被持久化。一旦客户机下线或者重新上线,上线前已经发布过的消息是不会被订阅到的。

  • 其次,Redis 客户端对象相应缓存有限制,一旦超过限制就会强制关闭 clinet,客户端还需重新订阅关系。

  • 那么 RedisPub/Sub 就不能用来作为消息队列,只能用来实时消息广播。在我们的业务场景中,Pub/Sub 通常作为无需持久化的实时消息推送,如用户加入房间通知、实时弹幕、协作文档光标位置等。

Q3. 如何保证大量状态更新时的性能和可靠性

A3. 分架构和 Redis 优化两个层面来回答:

  • 首先是架构层面

    1. 使用 Redis 集群架构,通过将不同直播间数据分片到不同的物理节点上,分散单点直播间巨大压力,避免单点瓶颈
    2. 通过读写分离分散 master 读密集操作压力。
    3. 最后是将状态存储与消息广播型集群功能分离,使用专门实例负责 Pub/Sub,配置高带宽,避免广播流量挤占状态存储 CPU/网络资源。
  • 其次是 Redis 优化,对于内存,合理控制 Key 大小,控制 String Value 大小;对于持久化,结合 RDB + AOF 保障需要持久化的数据,对于无需持久化数据则设置 save "" 禁用持久化。


Q4. 是否遇到消息积压问题?

A4. 其实这个问题本质就是因为 Pub/Sub 本身不持久化消息,但发布的消息会暂存在每个订阅者的输出缓冲区中。

积压触发条件有:

  • 消费者处理能力不足 (慢消费者):客户端(如 WebSocket 服务实例)消费消息的速度跟不上发布速度。

  • 网络延迟/抖动:消费者与 Redis 节点之间的网络不稳定。

  • 消费者宕机/重启:断开期间的消息会直接丢失(无积压,但需重建状态)。

这三种情况都非常常见,我们的解决办法一般是使用 Grafana 监测 Redis 实时指标,根据预警提前扩容 Redis 和消费者集群。

基础

缓存穿透

查询不存在的数据,但是 mysql 查询不到,导致每次请求都查数据库。

解决方案

  1. 缓存空值
  2. 布隆过滤器

缓存击穿

key 过期时,大量请求查询 key,瞬发请求压垮数据库

解决方案

  1. 互斥锁
  2. 逻辑过期

缓存雪崩

同一时段大量 key 过期,或者 redis 宕机

解决方案

  1. 给不同 key 设置 TTL + 随机值
  2. 利用 Redis 集群提高服务可用性
  3. 给缓存业务添加降级限流策略:nginx、gateway
  4. 给业务添加多级缓存:Guava、Caffine

双写一致性

mysqlredis 同步

旁路缓存

  1. 读数据:
    • 缓存命中,直接返回
    • 缓存未命中,查数据库、写缓存,设定超时时间
  2. 写数据:
    • 修改数据库,删缓存
    • 异步通知删除
    • 分布式锁

持久化

  1. RDB redis 数据备份文件

    • 把内存中所有数据记录到磁盘中。redis 重启后,从磁盘读取快照,恢复数据
    • save/bgsave 主动备份
    • 执行原理:
      • 主进程 fork 子进程
      • 子进程复制主进程页表 -> 同一片内存
      • 读数据写入 RDB (覆盖写)
      • 与此同时,主进程做写操作的时候,是拷贝一份数据再写 -> 子进程读到的则是当时的快照
  2. AOF 追加文件

    • 每个写命令记录在 AOF 文件(日志)
    • redis.conf 配置(默认关闭)
      • 可配置写频率(always/everysec/no
      • 可配置重写规则(比上次 AOF 大多少/体积多大)
    • 重写时可合并(set xxx … -> mset xxx xxx)
    • 适合数据一致性要求高的场合

数据过期策略

惰性删除和定期删除相结合

  1. 惰性删除

    按需触发、过期删除

    • CPU 友好
    • 内存不友好
  2. 定期删除

    定期定量检查(随机 key),删除过期

    • slow 定时
    • fast 频率变化

数据淘汰策略

由于内存不够

I/O 多路复用

阶段 1

  1. 用户进程调用 select,监听指定 socket 集合
  2. 内核监听 socket
  3. 任意 socket 数据就绪返回 readable
  4. 此过程用户进程阻塞

阶段 2

  1. 用户进程找到就绪 socket
  2. 依次调用 recvfrom 读数据
  3. 内核拷贝数据到用户空间
  4. 用户进程处理数据

select、poll、epoll

  1. select、poll 只会通知 socket 就绪,但不确定是谁,还需要用户自行遍历 socket;
  2. epoll 通知的同时,把已就绪的 socket 写入用户空间

数据结构

list 底层演化: 双向链表->压缩列表->快速列表

压缩列表

即连续内存 + 双向遍历(元素存储 offset,即前一个元素长度)

快速列表

即压缩元素 + 双向链表(不连续了)