Redis面试十二问:从SDS底层到分布式锁的实战复盘
Redis这套东西面试被问过无数次线上也排查过不少回。说它是“夺命十二问”一点不夸张很多问题看起来简单往深里一问就露馅。这篇就当是给自己做个复盘也顺便把我在实际运维、调优、写代码过程中踩过的坑、验证过的结论一并整理出来。这十二问不是网上随便抄来的八股每一问我都至少在线上一线环境里或者压测环境里亲手验证过一遍。如果你是刚接触Redis这套问题能帮你把主干的原理搭起来如果你已经用了一两年看看能不能不看答案先答一遍能答透六成以上说明你Redis的基础已经相当扎实了。1. 第一问到第三问底层结构与单线程模型1.1 第一问Redis的String类型底层为什么用SDS而不是C字符串这是最基础的底层问题但能讲清楚的人并不多。Redis自己实现了一个叫SDSSimple Dynamic String简单动态字符串的结构而不是直接用C语言传统的以\0结尾的char数组。C字符串有几个硬伤计算长度要遍历O(n)复杂度二进制不安全只要内容里出现\0就会被截断拼接和修改时容易缓冲区溢出需要手动管理内存。Redis里存的value经常是图片、序列化对象、计数器这些场景C字符串根本扛不住。SDS的设计是结构体里维护len已用长度、alloc已分配容量、flags类型标识后面跟字节数组。读取长度直接O(1)修改时会自动做空间预分配和惰性释放减少内存重分配次数。同时因为长度是显式记录的中间出现\0也不影响存二进制完全没问题。我印象很深的一次是给老项目做改造把Redis里的一份JSON序列化字符串从String换成了byte[]里面天然带着二进制内容换成SDS支撑的Redis String后存储和读取没有任何问题C字符串那种“读到第一个\0就停”的坑完全不存在了。还有一个容易忽略的点Redis为了节省内存SDS分了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64几种类型根据字符串长度选择不同的头避免每存一个短字符串都白白浪费好几个字节的结构体开销。这个细节能答出来面试官一般会眼前一亮。1.2 第二问Redis真的是单线程吗6.0之后还算单线程吗经典面试题而且答案其实一直在更新。核心结论是Redis执行命令的主流程也就是从读取请求、解析协议、执行命令、写回响应这个核心链路确实一直是以单线程方式运行的。之所以敢这么干是因为Redis的数据结构全部是内存操作性能瓶颈通常在网络IO和内存分配上多个线程去抢共享数据结构的锁反而会引入上下文切换和锁竞争性能不一定提升。但6.0开始Redis引入了多线程IO。也就是说网络读写这块可以分摊到多个线程上但命令的实际执行仍然在单线程主线程里完成。这样设计是因为单线程处理命令太快了瓶颈经常卡在read()和write()这种系统调用上把读写IO并行化能明显提升多核心下的吞吐。还有一个严格来说独立的机制像UNLINK、FLUSHALL ASYNC以及7.0左右的eviction这些耗时操作是在后台线程执行的不会阻塞主线程。Redis 7.0.9之后部分命令如ZADD等引入了子线程执行的优化也是异步化的方向。实操层面给大家一个建议如果只是做业务缓存不需要去调io-threads保持默认就行。我在压测环境里试过开启io-threads 4在纯GET场景下吞吐确实有提升但开启后需要把所有客户端的连接改成支持多线程复用的模型否则收益不明显还可能引入奇怪的问题。1.3 第三问String能存多大为什么单个value不建议太大官方说String类型的value最大是512MB。但真正在生产环境里单个value超过几MB就要敲响警钟了。原因有三层第一单线程模型下大value的序列化/反序列化、内存复制、网络传输都会阻塞主线程一个10MB的value存进去可能让整个Redis实例卡顿几十毫秒甚至更久。第二大key在扩容、持久化、主从同步时都会放大问题RDB快照要额外拷贝大对象AOF重写也要处理。第三内存碎片率高的情况大对象频繁分配释放会加剧碎片。我在线上遇到过最典型的例子是同事把一次接口的完整响应体里面带base64图片直接塞进了Redis单个key到了6MB多。那段时间经常收到redis.clients.jedis.exceptions.JedisDataException: ERR Protocol error: invalid multibulk length之类的报错排查半天发现是客户端超时再继续查定位到大key读写导致主线程卡顿连带其他小key的读写全部变慢。建议单个value控制在几十KB以内是最稳的。如果业务必须存大对象优先拆成多个hash字段或者用独立的存储服务别让Redis扛这种活。2. 第四问到第六问持久化、过期与淘汰2.1 第四问RDB和AOF到底怎么选数据丢了怎么办RDB是一次全量快照默认save 900 1这种规则触发或者手动BGSAVE生成dump.rdb。优点是恢复快、文件紧凑缺点是两次快照之间的数据可能丢。AOF是追加日志默认everysec每秒刷盘最多丢一秒数据缺点是文件大、恢复慢。混合模式aof-use-rdb-preamble yes是RDB做头、AOF增量做尾这也是我目前最推荐的。很多生产事故其实是配置问题造成的。比如appendfsync always每条写命令都刷盘数据安全是最高了但高并发下性能下降非常明显而且磁盘慢的话会让主线程阻塞。我见过有人图省事直接用always结果业务高峰期Redis写延迟从0.5ms飙到几十ms。生产环境的建议是RDB和AOF都开RDB负责快速恢复AOF负责兜底最后几秒的写入。appendfsync选everysec兼顾性能和可靠性。同时把no-appendfsync-on-rewrite yes打开避免AOF重写期间频繁刷盘拖垮性能。如果你对数据丢失特别敏感比如缓存数据库二合一的场景可以把everysec换成always前提是你能接受明显的性能回落。2.2 第五问Key过期了会立刻删除吗内存淘汰是怎么工作的很多人以为EXPIRE设置的过期时间到了key就没用了。实际上Redis用的是惰性删除加定期删除的组合策略不是实时删除。惰性删除访问key时才检查是否过期过期就直接删掉返回不存在。定期删除默认每100ms执行一次随机抽样部分设置了过期时间的key检查并删除过期的同时有个上限控制避免占用太多CPU。问题就来了如果有些key一直不被访问而且定期删除也没抽到它们它们就会一直占着内存。这种情况下内存淘汰策略就登场了。常见的有volatile-lru从已设置过期时间的key里淘汰最久没用的、allkeys-lru所有key参与淘汰、volatile-ttl过期时间最近的先淘汰、noeviction默认内存满了直接拒绝写。生产建议缓存场景用allkeys-lru最省心。但注意如果你用了分布式锁、消息队列这种不能丢的key就别设置过期时间也别让它们在淘汰范围内否则锁突然没了会出大问题。我踩过这个坑用Redis做分布式锁锁的key没设过期时间结果内存紧张时被LRU干掉了另一个节点立刻抢到锁两个线程同时执行了临界区。从那以后所有锁的key我都在名字里加前缀并且配合noeviction或者单独实例处理。2.3 第六问为什么INCR并发高时结果不准怎么保证计数正确这个问题在热词里出现过redis incr不准。很多人以为是Redis的INCR本身有并发问题其实恰恰相反INCR是原子命令单线程模型下绝对不会有计数不准的问题。所谓的“不准”通常是业务代码里用了“先GET再SET”的非原子操作比如先读出来在应用层加1再写回去两个并发请求同时读到旧值写回去就覆盖了最终结果比预期小。还有一种是脚本里用了Lua但是写得不对。Redis支持在服务端原子执行Lua脚本但如果你在脚本里对同一个key做了多次读写或者在循环里依赖外部时间、随机数逻辑一复杂就容易引入误差。实操解法计数必须用INCR、INCRBY这类的原子命令不要先读再写。需要“读取-计算-写回”这种复合逻辑时用Lua脚本保证原子性。如果计数是全局唯一的序号且并发极高直接用Redis的INCR别在应用层自己拼时间戳加随机数那个方案不仅在并发高时会重复而且序列单调性还差。检查“不准”的问题时先确认是不是客户端连接超时导致的重复重试重试会把同一个操作执行两遍导致计数偏大。这个我踩过客户端配置了retry网络抖动时同一个INCR被发了两次一天下来计数翻了一倍。另外INCR的结果是64位有符号整数别以为它永远不溢出。到9223372036854775807之后再自增会报错。真有人把GET请求计数做到这个量的最好提前拆key。3. 第七问到第九问高可用、主从与集群3.1 第七问主从复制延迟怎么产生的能不能缓解主从复制是异步的主节点执行完写命令后把命令传播给从节点从节点回放。这个过程中存在网络延迟和从节点回放耗时所以从节点的数据会略落后于主节点正常情况下毫秒级。但如果从节点开启了RDB持久化或者AOF重写CPU吃紧延迟会被拉大。关键风险点业务如果读从库就可能读到旧数据。很多系统这么干过突然出现明明刚写的数据读不到跑到从库上发现是不同的值。要缓解网络层面主从节点尽量同机房、同可用区降低RTT。从节点别部署太差的机器CPU和磁盘尽量和主节点持平。repl-backlog-size要大一点我看到很多默认1MB在写量大的场景很容易导致全量同步频繁触发。用WAIT命令可以阻塞到从节点同步N个副本但这会牺牲性能只在关键业务才用。我在一次活动大促前给主库加了一个从库由于从库初始化时做全量同步主库的RDB文件有20多GB数据传输期间主库写延迟升高还产生了网络拥堵导致正在进行的业务读写都变慢。后来学乖了大实例加从库要放在业务低峰期或者用repl-diskless-sync开启无盘复制减少主库磁盘IO影响。3.2 第八问哨兵模式和集群模式到底有什么区别这是热搜词里排得很靠前的题也是很多新人最容易混的。哨兵Sentinel模式解决的是高可用问题。它本身是一组独立的Redis进程监听主库和从库的状态。当主库挂了哨兵会做故障转移自动把某个从库晋升为主库客户端通过哨兵发现新的主库地址。但哨兵模式下所有读写压力还是集中在一个主库上从库只是备份和读扩展。集群Cluster模式解决的是扩展性和数据分片问题。Redis Cluster把整个数据空间分成16384个槽位每个节点负责一部分槽。客户端请求任意节点节点会计算key属于哪个槽然后转发到负责该槽的节点。它可以水平扩展写能力和存储容量都能随着节点数提升。两者不是二选一而是可以结合Cluster本身就内置了高可用机制每个分片可以配置主从节点主挂了从自动顶上。所以生产上要么用哨兵架构做读写分离要么直接用Cluster做分片加高可用。如果是中小规模数据量在几个GB以内哨兵足够如果是几十GB甚至更大或者写并发特别高直接上Cluster。3.3 第九问Cluster的槽位是怎么计算的为什么迁移时要小心客户端或者Proxy计算key归属槽位用的算法很简单CRC16(key) % 16384。注意是16384不是16383。不同的key由于哈希值不同会被分配到不同的槽上。Cluster启动时可以用cluster addslots手动给节点分配槽位也可以用cluster rebalance自动平衡。很多问题出在迁移上。迁移过程中槽位在节点间移动如果业务还在照常读写会出现MOVED或者ASK重定向。客户端如果实现了自动重定向问题不大如果客户端没实现就会出现一部分key读写失败。最典型的是Jedis老版本的Cluster模式在迁移时会频繁重定向性能下降明显。Lettuce在这块做得好一点但也会在迁移期间出现命令重试。我的经验是迁移数据前一定要先做容量规划计算好每个节点的槽位分布避免迁移过程中节点间数据量差距过大。重点检查大key比如一个list里有几百万个元素迁移时要序列化整个数据结构耗时很长。建议先用redis-cli --bigkeys扫一遍把大key打散或者提前处理。4. 第十问到第十二问缓存治理、分布式锁与SpringBoot实战4.1 第十问缓存穿透、击穿、雪崩到底怎么区分治理手段是什么这三个是Redis最常考的缓存治理题但很多人只是背概念。我换成实际场景来说。缓存穿透请求的数据在缓存和数据库里都没有每次请求都直接打到数据库。比如一个不存在ID的商品恶意请求可以轻松打爆DB。治理方式缓存空值短暂缓存比如60秒防空洞覆盖时间短用布隆过滤器把不存在的ID直接挡在缓存层之前接口层做参数校验和限流。缓存击穿某个热点key突然过期大量请求同时打到数据库。治理方式热点key设置不过期但要注意更新策略或者用互斥锁只允许一个请求回源数据库写缓存其他请求等待或者缓存value里存逻辑过期时间后台异步刷新。缓存雪崩大量key在同一时间过期或者Redis实例整体挂了所有请求全部打到数据库。治理方式过期时间加随机偏移避免同一秒集体失效用多级缓存本地缓存加Redis开启Redis高可用服务层做熔断降级。这里特别强调一下缓存空值的细节如果查一个不存在的用户缓存了空字符串那下次再查这个用户时就不能继续查DB了。但要注意这个空值不能和正常的“用户存在但没值”的字段混淆建议用特殊占位符比如--NULL--并且设置较短的过期时间防止缓存层堆积大量不存在的数据。4.2 第十一问Redis分布式锁怎么实现有哪些坑分布式锁是高频考题而且坑非常多。最简单的用SET key value NX EX 30加锁时带上NX不存在才设置和EX过期时间。释放锁时必须用Lua脚本判断value是否还是自己的是才DEL防止误删别人的锁。我见过一个经典错误A线程加的锁过期了B线程加锁成功A线程执行完释放锁把B的锁删了。用Lua脚本比较value就能避免。接着是锁续期问题。如果业务执行时间超过锁的过期时间锁会自动释放并发保护失效。业界方案是Redisson的看门狗锁的默认过期时间是30秒看门狗每10秒续期一次直到任务完成释放锁。这个机制解决了一半问题但如果你用的是RedissonClient.getLock锁的租约时间默认是30秒可以通过lock.lock(10, TimeUnit.SECONDS)重新指定。更严重的坑是主从切换导致锁丢失。A在主节点上加锁主节点还没同步给从节点就挂了故障转移后从节点升主B来加锁成功此时两个线程同时持锁。Redisson的RedLock解决思路是写入多数节点但实际部署成本和运维复杂度都不低。我个人的实践建议是核心的金额操作、库存扣减别指望Redis锁绝对安全一定要配数据库乐观锁或者消息队列的顺序消费做兜底。非核心场景Redis分布式锁完全够用。还有一个容易被忽略的点锁的粒度。我见过有人用一个全局锁锁住所有订单操作导致某几个大商户的操作互相阻塞QPS上不去。锁的key要尽量细化比如lock:order:100而不是lock:order:all。4.3 第十二问SpringBoot整合Redis为什么连接不上、报超时、序列化乱码整合Redis的标准步骤网上一搜一大把但从网上教程跳到生产环境经常会遇到三个问题。第一个是连接超时错误信息类似RedisCommandTimeoutException: Redis command timed out。我排查过很多次大部分原因不是Redis真的慢了而是线程池被耗尽业务高峰期所有线程都在等连接Lettuce的默认线程池是CPU核心数*4在高并发场景可能不够需要调整spring.redis.lettuce.pool.max-active和max-idle。网络或者防火墙导致的连接建立慢比如Redis服务器的tcp-backlog太小连接队列溢出。Redis慢查询把主线程卡住了比如KEYS *这种命令一执行就是全库扫描。第二个是序列化问题。默认的JdkSerializationRedisSerializer会把对象序列化成二进制可读性差、占用空间大。我通常配GenericJackson2JsonRedisSerializer处理valuekey用StringRedisSerializer。要注意一旦序列化方式改了旧的二进制数据是读不出来的要么清缓存要么迁移。另外泛型序列化反序列化时如果value里没有携带class信息反序列化会失败这一点Jackson和Gson表现不同需要测试好。第三个是可视化工具连接不上。这个其实很简单确认bind配置默认127.0.0.1只允许本机连接确认protected-mode是否开启默认开启时只能本机连确认密码配置requirepass生效。另外客户端工具选型上我用过RedisDesktopManager新版改名成了Another Redis Desktop Manager另外Redis Insight官方工具也不错查看key、执行命令、看慢日志都方便。SpringBoot整合时的配置可以这样起步spring: redis: host: 127.0.0.1 port: 6379 password: ${REDIS_PASSWORD:} timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3000ms代码侧推荐直接用StringRedisTemplate存字符串用RedisTemplateObject, Object存对象时一定要自定义序列化器别用默认的。还有opsForValue().setIfAbsent(key, value, timeout, TimeUnit.SECONDS)可以做原子占位是手写分布式锁的基础。5. 实操环境搭建与常用命令速查5.1 Redis有多种安装方式怎么选Linux环境我优先推荐用发行版的包管理工具或者官方源码编译。以CentOS系为例yum install redis往往不是最新版版本偏旧如果需要新特性直接去官网下载源码编译。macOS开发机用brew install redis最方便。Windows不是Redis的官方支持平台虽然有第三方移植版本比如tporadowski的Windows移植版但我只在本地做实验用过生产环境一律Linux。Docker安装Redis是我现在最常用的方式。一条命令拉镜像启动docker run -d --name redis \ -p 6379:6379 \ -v /data/redis/conf:/usr/local/etc/redis \ -v /data/redis/data:/data \ redis:7.2 redis-server /usr/local/etc/redis/redis.conf注意挂载目录的权限Redis容器里默认用户是redis宿主机目录权限不对会启动失败。多实例部署时每个实例单独目录不要共用。另外docker-compose部署主从时可以通过replicaof配置组成一主一从或一主多从。5.2 常用自检命令清单几个我几乎每天都会用的命令整理成了一张表方便做完诊断时对照场景命令说明查看所有keyKEYS *线上慎用只适合小库或维护窗口渐进遍历keySCAN 0 MATCH prefix:* COUNT 100不阻塞主线程推荐查看key类型TYPE key排查类型错误用查看TTLTTL key返回-1表示没设过期-2表示已过期查看内存INFO memory关注used_memory、maxmemory_policy查看慢日志SLOWLOG GET 20找出拖慢主线程的命令大key扫描redis-cli --bigkeys快速定位大key实时监控redis-cli --stat查看QPS与内存增量连接查看CLIENT LIST排查连接数突增发布订阅测试PUBSUB CHANNELS排查消息通道阻塞还有一个冷门的但好用redis-cli --latency可以看本机到Redis的网络延迟是否稳定排查连接超时非常有效。我每次遇到“Redis偶尔慢”的问题第一件事就是挂这个命令跑五分钟如果延迟毛刺很多基本就是网络或者机器问题。5.3 日志与监控怎么配置热词里提到“redis日志”很多人不知道怎么配。redis.conf里有两个关键项loglevel和logfile。生产环境建议loglevel notice既能看到启动、错误信息又不至于刷太多。排查问题时可以临时切到debug但别长期开着日志量很大。更建议的做法是把Redis的关键指标接入监控系统。至少盯四个指标used_memory和内存碎片率connected_clientsinstantaneous_ops_per_secrejected_connections。内存碎片率超过1.5就要考虑重启节点或者调整jemalloc的配置。连接数突增时优先查是不是代码里创建连接后没释放或者连接池配置过小导致排队。我用过一个开源工具redis_exporter配合Prometheus收集指标配合Grafana出面板能够直接看到命令耗时分布和缓存命中率变化。如果你只想用自带的命令INFO stats里的keyspace_hits和keyspace_misses算一下缓存命中率命中率低于80%说明缓存设计有问题该加的缓存没加。6. 我把这十二问浓缩成的三句话最后说一点个人体会。Redis容易出问题的场景其实集中在三块一是内存和淘汰策略二是持久化配置三是高并发下的原子性保障。大多数线上事故不是Redis本身脆弱而是使用方没有理解它单线程、内存型、异步复制这三个核心特性。每次线上出问题我都会先去翻INFO、SLOWLOG和latency而不是猜。工具本身很简单但它能告诉你真实发生了什么。多看几次你对Redis的感觉就出来了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →