十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis核心原理与生产实战:从数据结构到缓存治理

Redis核心原理与生产实战:从数据结构到缓存治理 说实话Redis大概是后端开发里人人都用过、但真正吃透的人最少的组件。我去过不少团队见过有人把Redis用得比数据库还溜也见过有人线上业务一过峰值缓存一崩数据库直接被打爆。这篇文章我想把Redis从底层到生产常见的坑完整过一遍为什么它这么快、五大数据类型底层到底是什么、持久化怎么选、穿透击穿雪崩怎么治、RedisTemplate序列化那些怪毛病以及主从搭建和可视化客户端怎么选。尽量不写教科书废话全是能直接在项目里落地的判断和教训。1. 先看清Redis的“快”到底快在哪数据结构与单线程模型1.1 一个请求在Redis内部是怎么走的很多人对Redis性能的印象停留在“它把数据放在内存里所以快”。这句话没错但它解释不了另一个问题同样在内存里跑为什么Redis比我自己写的Java HashMap服务快那么多因为“内存”只是必要条件真正的差距来自后续这一整套设计。一条命令进入Redis后大致路径是这样客户端通过网络把命令按照RESP协议编码发过来Redis的事件处理器通过IO多路复用机制感知到可读事件把命令读入输入缓冲区然后解析命令去全局哈希表里找到对应的key和value执行对应的数据结构操作再把结果写回输出缓冲区最后通过事件循环把响应发回去。这里面值得注意的点是Redis无论服务多少个客户端连接执行命令的核心线程只有一个。这个单线程模型在早期饱受争议但它恰恰是Redis性能神话的重要组成部分。单线程意味着没有锁竞争、没有线程切换开销、没有共享数据一致性问题所有操作天然原子。曾经有个老排查案例一个Redis实例同时扛着几万个连接CPU占用率却不到30%就是因为这些连接大部分时间都在等待IO事件真正执行命令的负担很轻。1.2 底层数据结构不只是String和Hash如果要给“Redis快”找一个最底层的支撑内存只是基础真正的主角是那套精简的底层数据结构。Redis官方文档把数据类型称为“数据类型”但在C语言实现里每个value都是以对象形式存在的对象的encoding字段决定了当前实际使用哪种底层结构。这些底层结构包括简单动态字符串SDS、双向链表、压缩列表ziplist、紧凑列表listpack、整数集合intset、跳表skiplist、哈希表hashtable。在Redis 7.x里listpack逐渐替换了ziplist用于Hash、List、ZSet的小数据量场景。举个例子SDS相比C语言原生字符串额外记录了len字段所以获取字符串长度是O(1)而且因为记录了空闲长度追加字符串时不需要频繁重新分配内存还能避免缓冲区溢出。跳表则解决了有序集合在插入、删除、范围查询时的高效平衡问题。Redis选择跳表而不是红黑树一个重要原因是跳表的实现更简单在范围查询场景下天然有序不需要做复杂的旋转操作。1.3 单线程模型为什么不会成为瓶颈有人会问单线程执行命令如果某个命令特别耗时岂不是整个Redis都卡住这是对的所以Redis里坚决不能用KEYS这类O(N)扫描命令这也是所有生产环境规范里反复强调的一条。但Redis引入多线程有一个渐进过程。Redis 6.0之后网络读写阶段引入了IO多路复用加多线程也就是多个线程可以同时接收和发送网络数据但命令执行仍然在单线程里完成。这样做的好处是网络IO瓶颈被化解同时命令执行的原子性保持住了。Redis的设计哲学向来是“把复杂问题放在事件循环里而不是靠多线程去扛”。这里我想说一个很多人没意识到的点Redis之所以能在高并发下稳定输出低延迟另一个关键因素是它避免了很多隐性的序列化和拷贝开销。它不像关系型数据库需要解析SQL、生成执行计划、操作缓冲池它直接用自定义协议解析命令并在精心设计的内存结构上做指针操作。理解了这一层后面看持久化和过期策略时思路会顺很多。2. 五大数据类型的使用边界与底层编码选型判断2.1 RedisObject与5种value类型的真实样子我们在业务代码里接触到的String、Hash、List、Set、ZSet在Redis内部并不是直接按这五套结构存储的。每个value会包装在一个RedisObject结构里其中包含type数据类型、encoding编码方式、ptr指向底层数据的指针、lru记录访问时间等元信息。Redis会根据value的大小和元素数量动态选择最节省内存的编码方式。判断当前key到底用的什么编码可以用一条命令直接查redis-cli object encoding mykey比如你连续设置几个String值会发现一个有意思的现象整数类型的value返回int短字符串返回embstr超过44字节的字符串返回raw。同一个命令底层结构可能完全不一样。这些细节平时用客户端感知不到但如果你在做内存优化或性能调优就有必要搞清楚了。2.2 从编码方案看选型紧凑结构、跳表和哈希表的取舍String类型的编码分为三种int、embstr、raw。int用于整数value直接存储在指针字段里不额外分配内存。embstr适合较短的字符串对象头和字符串数据在连续内存里一次分配raw则用于长字符串由SDS保存。Redis 7.x中embstr和raw的分界线是44字节这是综合考虑内存分配效率后的结果。Hash类型在小数据量时使用listpack旧版本为ziplist当元素数量超过512个或某个value长度超过64字节时会转为hashtable。为什么listpack是一块连续内存对缓存友好遍历很快但插入删除需要搬移数据数据量大了效率会下降。hashtable则用空间换时间操作稳定在O(1)。List在Redis 3.2之后底层是quicklist可以理解为把多个ziplist/listpack用双向链表串起来兼顾了内存连续性和插入删除的灵活性。Redis 7.0之后每个quicklist节点直接使用listpack。Set如果全是整数且数量不大底层是intset这是一个有序整数数组内存极其紧凑一旦加入字符串或数量增大就转为hashtable。ZSet在元素少时同样用listpack元素多了就用跳表加哈希表的组合跳表负责按分数排序和范围查询哈希表负责按member直接查分数。2.3 日常开发中最容易用错的几个场景看到这你就可以回答一个经典面试题为什么ZSet的底层是“跳表哈希表”而不是一棵红黑树答案不仅是范围查询友好更因为Redis既要支持按member快速取score哈希表又要支持按score排序和范围扫描跳表。实际项目里最常见的错误是把ZSet当作一般的排序工具却忽略了每个member在跳表里有额外的指针开销结果存了几百万成员后内存涨得吓人。另一个常见错误是使用String存储大对象JSON动不动几十KB一旦并发高网络和内存都吃紧。对这种场景更好的做法是拆成Hash按字段读取或者先用消息队列把对象转成合适的结构再缓存。还有人在Set里存了大量长字符串ID却没有考虑过去重结构的内存估算最后不得不临时扩容内存。选型前先估算一下单个数据的内存成本能省掉很多线上事故。3. 持久化没那么神秘RDB、AOF和现代Redis的取舍3.1 两种机制完全不同的故障恢复代价Redis是内存数据库数据默认放在内存里如果不做持久化一旦进程退出或机器重启缓存数据全没了。RDB和AOF就是两种完全不同的持久化思路。RDB是“快照式”持久化它把某个时间点整份内存数据写成二进制文件。执行bgsave时Redis fork出一个子进程子进程负责把内存里的数据写入临时RDB文件写完后替换旧文件。这里的关键机制是写时复制COWfork瞬间父子进程共享同一份物理内存之后父进程如果修改了某页内存操作系统会把该页复制一份给父进程子进程仍然保留fork时的旧数据。所以RDB的保存过程不影响主线程的业务读写。但RDB有两个天然短板一是两次快照之间可能丢数据默认配置下可能丢最近几分钟甚至更长时间的数据二是当内存特别大时fork操作本身可能阻塞主线程数百毫秒造成明显的请求延迟毛刺。AOF则是“追加式”持久化每次写命令都会追加到AOF文件末尾相当于记录操作日志。恢复时重放这些命令。AOF的落盘策略通过appendfsync配置控制always每条命令都同步刷盘性能最差但最安全everysec每秒刷一次最多丢一秒数据no交给操作系统决定刷盘时机性能最好但丢失窗口最大。3.2 AOF重写与RDB混合持久化到底怎么配AOF文件会不断膨胀比如你执行了一百次incrAOF里可能记录一百条命令但实际恢复时只需要一条set结果。所以Redis设计了AOF重写机制通过fork子进程重新生成一份紧凑的命令集。到了Redis 4.0之后更推荐的做法是开启混合持久化appendonly yes appendfsync everysec aof-use-rdb-preamble yes开启混合持久化后AOF文件重写时前半部分是RDB二进制快照后半部分追加重写期间产生的新命令。这样兼顾了RDB恢复快和AOF丢数据少的优点。我自己的生产配置一般就是这套组合。如果Redis只当纯缓存用丢了可以从数据库重建那甚至可以关闭持久化用主从加哨兵来保证高可用。3.3 我自己在持久化配置上踩过的坑分享一次真实事故。有一年我维护的Redis实例内存到了90GB业务方反馈某个时间段的请求P99飙高。排查后发现慢请求时间点正好撞上定时bgsave的fork时段fork阻塞了主线程将近700毫秒。内存越大、fork越慢这是必然的物理规律。后来解决办法很土也有效把快照频率降下来同时把这个实例改成纯缓存用途关闭RDB和AOF全量靠上游数据库重建缓存再配合主从复制提供高可用。如果你不能接受丢数据那就把持久化放到从节点执行主节点不bgsave从节点负责周期性保存这样主节点的fork压力就消失了。还有一个容易被忽略的习惯性错误同时开着RDB和AOF时Redis启动加载数据的顺序优先级要搞清楚。AOF文件中如果配置了aof-use-rdb-preamble会先按RDB格式加载再追加AOF增量但如果AOF文件损坏Redis默认会拒绝启动并提示用redis-check-aof修复。我之前遇到过因为磁盘写满导致AOF文件不完整的情况还好有备份否则只能人工重建数据。所以持久化文件做定期备份、对磁盘容量做监控比调优参数更重要。4. 缓存治理与分布式锁生产环境最容易翻车的地方4.1 穿透、击穿、雪崩的成因与解决方案这三个概念经常被混在一起但成因完全不同。缓存穿透是查了一个数据库里也不存在的数据导致请求每次都绕过缓存直接打到数据库。对付穿透最有效的是在查询前用布隆过滤器判断key是否可能存在也可以对不存在的key做一个短暂的空值缓存但空值缓存要注意设置较短的过期时间。缓存击穿是某个热点key恰好过期同时来了大量请求所有请求都发现缓存没有然后一起涌向数据库。这时因为key本身是热点流量非常大数据库很容易被打垮。解决思路有三个方向热点数据干脆设置很长的过期时间加互斥锁让只有一个请求去数据库查询并回填缓存或者采用逻辑过期方案在缓存value里额外存一个过期时间字段后台异步线程刷新真数据读线程发现逻辑过期直接返回旧数据。缓存雪崩则是一大批key同时过期或者整个Redis实例宕机。大量key同时过期会导致数据库瞬间压力激增。解决方案很简单也很实用在设置过期时间时加一个随机偏移量比如base过期时间加0到300秒的随机值把集中失效打散。如果是Redis集群整体不可用就得依赖多级缓存和限流降级了。4.2 分布式锁的演进setnx、Redisson到RedLock分布式锁是Redis在缓存之外最常见的用途。早期很多文章教人用SETNX加EXPIRE实现锁这是典型的错误示范因为SETNX和EXPIRE是两条独立命令中间进程一旦崩溃锁永远不会释放。正确姿势是一条命令把值和过期时间一起设置SET lock_key unique_value NX PX 10000这行命令的意思是只有key不存在时才设置成功并给锁设置10秒过期时间。释放锁也不是简单的DEL因为可能出现这种情况线程A持锁超过10秒锁自动过期线程B拿到锁后A才执行完去释放锁结果把B的锁误删了。正确释放必须校验value是不是自己的唯一标识if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end用Lua脚本保证判断和删除的原子性。在实际项目中我强烈建议直接用Redisson不要自己卷这套逻辑。Redisson的RLock默认30秒过期时间但它有一个看门狗机制只要业务线程没结束每隔10秒自动续期防止业务执行时间超过锁的过期时间。Redisson底层就是用Lua脚本完成加锁、续期、释放的可靠性比自己写高很多。4.3 锁的续期和释放细节关键时候能救命Redisson的看门狗机制说起来简单但它解决了一个很隐蔽的问题锁超时时间设置多长才合理设置太短长任务没执行完锁就过期了设置太长一旦持有锁的进程宕机其他线程要多等很久。如果用Redisson你根本不用纠结这个问题它默认会续期相当于用一个后台线程持续给这把锁“续命”直到业务执行完。但如果你的团队不想引入Redisson手动实现时有个经验锁的过期时间不要拍脑袋定10秒要根据业务中最耗时的接口预估一个上限再乘1.5到2的系数。同时必须在finally块里释放锁释放前校验value。RedLock要不要用是个争议话题。RedLock的思路是同时向5个独立Redis实例申请锁至少成功3个才算加锁成功用来应对主节点故障切换造成的锁丢失。但很多分布式系统专家论证过RedLock并不完美因为它在持有锁期间如果发生GC暂停锁照样过期之后其他节点仍然能获得锁逻辑上无法绝对安全。我的观点是普通业务场景用单实例加Redisson就够用了如果真到了对锁可靠性要求极其苛刻的金融级场景更好的做法是换用具备强一致性的协调组件而不是在Redis上做文章。5. RedisTemplate序列化的坑与increment()报错复盘5.1 为什么你存进去的key长得不对Spring Boot项目里只要引入spring-boot-starter-data-redis大家都会用RedisTemplate但绝大多数人对默认序列化策略没有感知。Spring Data Redis 2.x默认使用JdkSerializationRedisSerializer也就是Java原生序列化key和value都会被序列化成一段二进制乱码。你明明往Redis里存了一个key叫user:100用redis-cli一查看到的是\xAC\xED\x00\x05t\x00\x0Auser:100直接傻眼。这不仅是难看的问量Java原生序列化的体积大、效率低而且跨语言完全没法反序列化。解决方式是自定义RedisTemplate的序列化器生产里最常用的是key用StringRedisSerializervalue用GenericJackson2JsonRedisSerializer或Jackson2JsonRedisSerializer。StringRedisTemplate则内置了String序列化器所有key和value都按字符串处理适合计数器和简单缓存场景。5.2 一例“not an integer or out of range”报错的完整排查链路有次同事跑一个秒杀扣库存的接口日志里突然出现RedisTemplate执行increment报错的异常核心信息是ERR value is not an integer or out of range他第一反应是Redis里存的不是数字但用RDM可视化工具点开一看value明明显示是100。我让他用redis-cli仔细查一下原始二进制发现value前面多了一堆二进制前缀原因是RedisTemplate的value序列化器还是默认的JdkSerializationRedisSerializer。increment命令要求value本身必须是整数形式的字符串但Redis读到的却是Java序列化的二进制流当然报not an integer。这个问题的完整排查链路可以总结成三步第一步用redis-cli get key看原始存储内容确认是不是纯数字字符串第二步用debug object或object encoding确认底层存储的合法性第三步检查RedisTemplate的valueSerializer配置尤其是opsForValue和opsForHash的序列化器是否分开配置。修复倒是简单计数器应用统一使用StringRedisTemplate或者为RedisTemplate单独配置valueSerializer为StringRedisSerializer。要注意的是这类序列化问题如果之前已经写入了脏数据修复代码后还要清理一遍旧key否则老key还是会让increment继续失败。5.3 生产环境里的序列化选型配置我项目中比较稳定的RedisConfig大概是这个样子Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }但这里有个隐患得提醒你GenericJackson2JsonRedisSerializer序列化时会带上一个class字段用来记录对象的全限定名反序列化时才能还原类型。这个设计对Java单语言项目很方便但如果你后面要接Go、PHP等其他语言的客户端这些客户端看到class字段会非常难受。跨语言项目更推荐value全部String然后各自用JSON库解析或者使用Jackson2JsonRedisSerializer并且自定义ObjectMapper处理多态。还有一个细节容易被忽略如果用默认的RedisTemplate存了一个Long但value序列化器配的是Jackson实际存到Redis里是123这个字符串此时执行increment同样会失败因为字符串123两边带的是JSON语义Redis不认。计数器、库存这类严格数字场景一律用StringRedisTemplate最省心。6. 从装环境到运维Docker主从、Windows版本和可视化客户端6.1 Docker快速搭一套主从并验证主从复制是Redis生产环境的高可用基础用Docker本地搭一套非常快。我习惯先建一个专有网络这样容器之间能用容器名互相解析比记IP方便docker network create redis-net docker run -d --name redis-master \ --network redis-net \ -p 6379:6379 \ redis:7-alpine \ redis-server --appendonly yes docker run -d --name redis-slave \ --network redis-net \ -p 6380:6379 \ redis:7-alpine \ redis-server --replicaof redis-master 6379 docker exec redis-slave redis-cli info replication最后一条命令如果看到role:slave并且master_link_status:up说明主从已经建立。这里有个容易踩的坑老教程喜欢用--link参数但Docker已经废弃了这个方式同网络下直接用容器名做DNS解析才是最稳定的做法。如果主节点配了密码从节点的命令里需要加masterauth参数否则主从同步会一直失败。6.2 Windows环境下的Redis安装与作为服务运行很多开发者的本机是Windows但Redis官方并不直接提供Windows版本安装包。常见的替代方案有这么几条用WSL安装Linux版、用Docker Desktop跑Linux容器、或者下载第三方移植的Windows发行版。如果你只是本地开发调试我建议直接用Docker Desktop因为它跟生产环境的Linux版Redis行为最一致不会遇到莫名其妙的兼容性问题。如果非要在Windows下直接跑exe也能找到zip包解压即用的版本解压后目录里有一个redis-server.exe和一个redis.windows.conf。双击exe直接启动配置文件可以用参数指定redis-server.exe redis.windows.conf如果希望开机自动运行可以注册成Windows服务redis-server.exe --service-install redis.windows.conf --loglevel verbose redis-server.exe --service-start需要卸载服务就执行redis-server.exe --service-uninstall需要提醒的是这类Windows移植版大多是老版本可能在Redis 7.x的新特性上落后生产环境强烈建议用Linux或Docker。6.3 可视化客户端怎么选RDM、Another Redis Desktop Manager、RedisInsight可视化客户端的选择因人而异但有几款我用过之后觉得值得推荐。老牌的Redis Desktop ManagerRDM非常经典但新版本已经改名为RESP.app部分高级功能开始收费。开源免费替代品里Another Redis Desktop Manager是做得比较成熟的一个跨平台界面现代支持集群连接、内存编辑器对日常排查数据非常方便。官方出品的RedisInsight则是在线诊断能力最强的自带内存分析、慢日志查询、命令监控和可视化树状展示适合做实例巡检。我的习惯是日常排查用Another Redis Desktop Manager因为轻量、顺手线上问题定位用RedisInsight因为它能直接看到大key分布和内存占用情况。连接生产环境时一定不要直连通过跳板机或者临时打开SSH隧道更安全。6.4 安全与日常运维检查清单Redis默认配置对安全性的要求其实很低没有密码、监听所有网卡、有危险的FLUSHALL命令。如果直接把这样的Redis暴露在公网上用不了多久就会被人入侵轻则数据被清空重则被当成矿机。我在生产环境上线Redis前基本都会照着下面这份清单过一遍设置强密码requirepass至少16位复杂口令Redis 6.0之后还可以用ACL给不同应用分配不同权限。修改bind配置只监听内网IP不要使用0.0.0.0。如果Redis和应用同机部署直接监听127.0.0.1最保险。保持protected-mode yes在没有密码配置时这个保护模式可以拒绝外部IP的远程连接。禁用危险命令通过rename-command把FLUSHALL、FLUSHDB、KEYS、CONFIG重命名或禁用Redis 6.0之后更推荐用ACL粒度控制。设置maxmemory如果Redis缓存永不过期且没有上限内存很快会被写满设置淘汰策略allkeys-lru可以保证它在内存压力下仍然可用。监控关键指标used_memory、connected_clients、rdb_last_bgsave_status、slowlog日志长度这些指标配合告警能提前发现问题。关于扫描大key千万不要在生产环境直接跑KEYS *这是线上事故制造机。替代方案是使用redis-cli的--bigkeys参数做离线扫描或者用SCAN命令分批遍历。无论哪一种都不会阻塞主线程太长时间。最后再分享一个小经验Redis的学习路径千万别只停留在背八股文最好的方式是自己用Docker搭一套主从把主从同步、故障切换、持久化配置全部亲手跑一遍然后故意制造一次缓存穿透、一次序列化报错从报错日志一路追踪到根本原因。这样操作一轮下来你对Redis的理解会超过绝大多数只会敲命令的人。
返回列表