开头我就直说了:Redis 这个东西,凡是做后端开发的,几乎没人敢说自己没用过。但"用过"和"用明白"之间,隔着一条很宽的技术鸿沟。我见过不少项目,Redis 挂了,缓存穿透把数据库打崩了,程序员一脸懵——Redis 不是号称高性能吗?怎么还能出这种事?问题不在于 Redis 本身,而在于使用的人只把 Redis 当成了一个"速度更快的 Map"。这篇文章我想换一个讲法:不按官方文档的目录一项项罗列命令,而是从真实项目会遇到的痛点和问题出发,把 Redis 的核心机制拆开看,再给一套完整的 Java 项目落地实践。无论你是在校学生准备面试,还是工作两三年的后端开发想系统补齐 Redis 短板,这篇文章应该都能让你少走不少弯路。1. 先把底层的"快"和"稳"搞清楚:Redis 的运行模型1.1 单线程模型到底意味着什么很多人第一次接触 Redis,听到的第一句话就是"Redis 是单线程的,所以快"。这句话既对也不对。对的一面在于,Redis 确实把所有命令的执行放在了一个主线程里,避免了多线程锁竞争和上下文切换的开销;不对的一面在于,如果你因此以为 Redis 在多核 CPU 上跑不满、浪费资源,那就误会了。Redis 的单线程是指"处理命令"是串行的,但它同时用了多路复用的 I/O 模型(select / epoll / kqueue 这一层),能够同时监听成千上万个客户端连接。你可以把它这样类比:一个能力超强的服务员,一个人同时接待很多桌客人,每桌点了什么菜他记得清清楚楚,上菜速度极快;但如果某桌客人非要拉着服务员聊十分钟电话,那其他桌全都得等着。这也是为什么 Redis 官方和社区反复强调:千万不要在 Redis 里存大 key 或执行慢命令(比如 KEYS *、对一个百万元素的 Hash 做 HGETALL),因为一旦主线程被阻塞,全站所有请求都会排队,Redis 的整体吞吐瞬间崩塌。1.2 命令执行的串行特性给业务带来的启发单线程串行执行,这本身是一个强大的业务设计工具。很多分布式场景下需要保证原子性的操作,如果依赖数据库锁或者分布式锁,会有一定的网络开销和实现复杂度;但在 Redis 里,因为命令天然是串行执行的,只要把"检查-写入"两步合并到一个 Lua 脚本或一个原子命令里,就能达到类似的效果。举个例子,限制某个用户每分钟最多发 10 条消息。用数据库实现要开事务、加锁;用 Redis 则可以用计数器 + 过期时间的组合,或者直接跑一段 Lua 脚本,在脚本里完成计数校验和自增。由于 Redis 执行脚本期间不允许其他命令插入,整个逻辑是原子且互不干扰的。这也引出一个非常关键的设计原则:能用一条命令完成的,绝不分成多条命令;必须分成多条的,优先考虑 Lua 脚本,而不是在 Java 代码里串行调用。2. 五种数据结构的选型逻辑与应用实战2.1 String 类型的真实利用率比你想的高String 是所有 Redis 类型的基础,本身可以存字符串、数字,也可以存二进制数据(比如小尺寸的图片字节流)。在实际项目中,String 最常见的用途远不止缓存 JSON:第一是计数器:INCR、DECR、INCRBY这类命令天然支持自增自减,原子性由 Redis 保证,可以用来做访问次数统计、库存扣减、消息未读数等场景。注意:如果你的计数需要做持久化,要考虑 RDB 和 AOF 的落盘策略;如果是纯实时指标,放在内存里定期做快照即可。第二是分布式锁的最简实现:SET key value NX EX 5(NX 表示不存在才写入,EX 表示过期时间)。这里建议带上一个唯一标识作为 value,这样删除锁时先比较再删除,避免误删别人的锁。后面第 5 章会给完整的 Java 代码。第三是限流:滑动窗口限流可以用 ZSet 实现,但如果是固定窗口限流,用 String 的SETNX加过期时间就非常方便。这里我想多说一句:使用 String 时,最容易犯的毛病是把 value 存得过大。单个 String 超过 10KB 就要警惕了,尤其是大 JSON。如果确实需要一个对象的多个字段,优先考虑 Hash。2.2 Hash:对象缓存的最佳载体很多新手喜欢把整个对象序列化成 JSON 塞进 String,其实绝大多数场景下,Hash 是更合适的选择。Hash 的每一个 field 可以理解为对象的一个属性。以用户信息为例,你可以把 User 对象的 name、age、avatar 分别存入同一个 key 的各个 field。这样带来的好处是:更新某一个字段时不需要把整个大 JSON 读出来再反序列化再写回去,只需要HSET user:1001 age 26一条命令。尤其是在读多写少的场景下,Hash 的内存效率和更新效率都明显优于 String 存的整块 JSON。底层结构上也值得一提:当 Hash 的字段数量少且字段值不大时,Redis 会使用紧凑的内存编码(6.x 之后逐步演进,方向上是从 ziplist 过渡到 listpack),随着元素增多才转换成 hashtable。这意味着小 Hash 非常节省内存,很多 Redis 内存优化方案都建议用 Hash 来压缩存储批量数据。我个人的建议是:缓存单一实体对象,优先选 Hash;缓存"一个整体且很少改动"的数据,比如配置项、页面静态片段的字符串,才选 String。2.3 List、Set、ZSet 各自的不可替代性List 按照插入顺序存储,左边推入右边弹出,非常适合做消息队列、简单的时间线列表。它的LPUSH+BRPOP模式,可以作为一个可靠度够用的异步队列。对比 RabbitMQ 这类专业 MQ,Redis List 队列少了一些复杂路由和可靠投递语义,但胜在轻量、不需要额外部署中间件。小规模项目里用这个方案,是一件非常务实的做法。Set 的核心价值是去重和集合运算。抽奖、签到、关注列表交集、两个人之间的共同好友这类场景,直接用SADD、SINTER(交集)、SUNION(并集)、SDIFF(差集)就能高效完成。如果不用 Set,用数据库做这些运算不仅 SQL 麻烦,数据量大时性能也很难看。ZSet 是在 Set 的基础上给每个元素加了一个 score(分数),按分数排序。这个数据结构实在太适合排行榜了,比如积分榜只需要ZADD leaderboard 100 userA,然后ZREVRANGE leaderboard 0 99 WITHSCORES就能拿到前 100 名。它还能用来做延迟队列:把任务执行时间戳作为 score,定时用ZRANGEBYSCORE取到期的任务。还有一种用法经常被忽略:ZSet 实现分布式限流的滑动窗口。把每个请求的时间戳塞进 ZSet,score 就是时间戳,滑动窗口检查窗口内元素数量,超出阈值就拒绝。这个方案在实现复杂度和精度之间平衡得很好。2.4 为什么 Redis 7.0 之后的 ZSet 更值得关注ZSet 的底层传统上是"跳跃表 + 字典"的组合,跳跃表保证排序和范围查询的高效,字典保证按成员查分数的 O(1) 速度。在 Redis 7.0 中,官方引入了一个叫 listpack 的新底层结构,替换掉了部分场景中的 ziplist,目的是解决 ziplist 在连锁更新场景下的性能抖动问题。简单说,小数据量时用紧凑内存结构,大数据量时自动升级为跳跃表。这套动态切换机制,是 Redis 能在大数据量下依然保持稳定延迟的重要原因。对业务开发来说,你不需要直接操作这些底层结构,但理解它有助于你判断一个 ZSet 在"元素不多但 score 频繁更新"的场景下是否适合。通常来说,ZSet 的成员数量控制在几十万以内,性能表现都很理想;超过这个量级,建议考虑分片存储或换用专业组件。3. 缓存穿透、击穿、雪崩:防御体系的完整搭建这是面试高频题,也是线上故障的高发区。我把三个问题放在一起讲,因为它们经常被混淆,但本质上发生的原因和应对手段完全不同。3.1 缓存穿透:恶意请求直达数据库的坑穿透指的是:请求的数据在缓存和数据库中都不存在。因为缓存里没有,每次请求都会打到数据库,数据库压力骤增。最典型的是用不存在的用户 ID 连续刷接口,此时缓存永远不可能命中。解决穿透的思路有三个方向:一是接口层的参数校验。对明显不合法的请求(比如 ID 为负数或超大数值)直接拒绝,这是成本最低的第一道防线。二是缓存空值。查数据库发现没有数据,也往 Redis 里写一个空值,过期时间设置短一些,比如 60 秒。这样后续同样的请求就能命中缓存,不会打到数据库。缺点是会缓存大量空 key,要在 key 的设计上做好区分,避免污染正常数据。三是布隆过滤器,这也是我推荐在生产环境使用的方式。布隆过滤器是一个很巧妙的数据结构:它用多个哈希函数把元素映射到一个位数组上,判断"某个 key 是否存在",如果它说"不存在",那一定不存在;如果它说"存在",可能误判(但概率很低,可以通过参数控制)。在查询缓存之前先过一遍布隆过滤器,不存在的 key 直接拦截在入口层,数据库几乎不会收到无效查询。Java 工程里可以用 Redisson 自带的布隆过滤器,也可以用 Google 的 BloomFilter 自行实现。初始化时把已知的合法 ID 集合预先放入,后续新增元素同步加入即可。3.2 缓存击穿:热点 key 过期的瞬间击穿和穿透一字之差,意思却完全不同:击穿指的是某个热点 key 在缓存过期的那一瞬间,大量请求同时打到数据库。数据库单点扛不住,可能直接宕机。最常见的修复方案是互斥锁。当缓存没有命中时,不直接查数据库,而是先尝试获取 Redis 分布式锁;拿到锁的线程去查数据库并回写缓存,其他线程排队等待并重新查缓存。这个方案实现简单,但有个缺点:如果热点 key 重建时间较长,会有一批线程阻塞等待,接口延迟变大。另一个方案是逻辑过期。也就是缓存里的 value 并不设置 Redis 物理过期时间,而是存业务数据和过期时间戳;每次读取时检查逻辑是否过期,如果过期则返回旧数据,同时异步启动一个后台线程更新缓存并替换为新时间戳。这个方案能保证用户请求基本不会被阻塞,但实现复杂度更高,要处理并发下多次触发更新任务的问题,通常配合分布式锁保证只有一个人去更新缓存。我的建议是:如果项目并发量中等(Redis QPS 几千到几万),互斥锁足够;如果某个 key 会被几十万甚至上百万的请求同时打,必须考虑逻辑过期方案。3.3 缓存雪崩:成片 key 同时失效雪崩指的是:大量 key 在同一时间段集中过期,导致请求分散命中到各个 key 时,缓存整体都失效了,数据库流量瞬间增加数倍甚至数十倍。这和击穿很像,但范围不是一个 key,而是一大片 key。最简单的防御手段是给过期时间加随机扰动。比如原本统一过期时间是 10 分钟,改成 10 分钟 ± 1~3 分钟的随机数,这样各个 key 的过期时间就错开了,数据库不会在某一瞬间收到全部回源请求。我见过不少项目,缓存 key 的过期时间写成了一个固定的常量,比如set(key, value, 3600),这种写法在数据量不大时问题不明显,但当你一次性预热了大量 key,或者大量 key 都在同一时间段被写入时,雪崩几乎是必然的。所以请把"过期时间增加随机值"写进自己的编码习惯里。还有一层是多级缓存:热数据放在本地缓存(如 Caffeine)里,Redis 做二级缓存,数据库在最底层。即使 Redis 失效了,本地缓存还能顶住一部分流量,给 Redis 重建赢得时间。3.4 快速区分三类问题的排查口诀穿透是"数据压根不存在",特征是数据库收到的请求全是查不到数据的查询,Redis 中始终没有对应 key;击穿是"单个热点 key 失效",特征是某一瞬间某个 key 的流量冲垮数据库,排查时能看到异常请求集中在同一个 key;雪崩是"大量 key 同时失效",特征是时间上对齐,缓存命中率大幅下降,数据库压力呈阶梯式上升。4. 内存管理与持久化机制:数据不会凭空消失4.1 内存淘汰策略:Redis 内存满了之后会发生什么Redis 的数据存在内存里,这既是它快的原因,也是它的软肋。你必须提前想清楚:当内存写满时,Redis 会怎么做?默认情况下,写入会直接报错(OOM command not allowed when used memory > 'maxmemory'),整个服务虽然活着,但已经无法写入新数据了。在配置文件 redis.conf 中有一项maxmemory-policy,常见的有以下八种策略:策略含义适用场景noeviction内存满时拒绝写入某些不允许丢数据的场景,但会导致写入失败allkeys-lru所有 key 中淘汰最近最少使用的通用缓存场景(最常用)volatile-lru仅对设置了过期时间的 key 淘汰 LRU希望没有过期时间的 key 永不被淘汰allkeys-lfu所有 key 中淘汰最不经常使用的热点分布不均、存在极热门 key 的场景volatile-lfu仅对设置了过期时间的 key 淘汰 LFU同上,但保护未过期 keyallkeys-random随机淘汰所有 key数据分布均匀、无需优先级的场景volatile-random随机淘汰设置了过期时间的 key极少使用volatile-ttl淘汰剩余存活时间最短的 key希望尽快腾出空间的场景生产环境我建议优先考虑allkeys-lru或allkeys-lfu。如果你的业务有明确的冷热数据区分,LFU 通常比 LRU 更能抵抗"偶发扫库"类操作对热数据的覆盖。具体怎么选,要结合业务特征,但别选 noeviction,除非你确定内存永远不会被打满。4.2 持久化:RDB 和 AOF 到底怎么取舍Redis 虽然叫缓存,但很多人把它当数据库用,存储的数据具有一定的资产属性,这时候你不能只依赖内存,必须开启持久化。持久化就涉及两个机制:RDB(快照):按照配置的时间间隔(比如 900 秒内有 1 次修改,或 300 秒内有 10 次修改)生成整个数据集的二进制快照文件。优点是文件紧凑、恢复速度快;缺点是两次快照之间的数据可能丢失。AOF(追加日志):记录每一条写命令,追加到文件中。优点是数据安全性高,可以把丢失范围控制在几秒甚至一条命令内;缺点是文件体积大,恢复速度比 RDB 慢。AOF 有三种刷盘策略:always(每条命令都刷盘,最安全但最慢)、everysec(每秒刷一次,推荐)、no(交给操作系统决定,安全性最低)。组合使用时,官方现在的默认做法是打开appendonly yes,并开启aof-use-rdb-preamble yes——AOF 文件头部可以是一个 RDB 格式的二进制快照,后面再追加增量命令。这样既保证了数据恢复速度,又保证了增量数据的安全性。企业在重启或迁移时,通常还会配合redis-check-aof --fix做完整性检查。4.3 一个容易踩的坑:持久化也要考虑 fork 阻塞不管选择哪种持久化方案,在生成快照或重写 AOF 时,Redis 会 fork 出一个子进程,而 fork 这个动作本身会短暂阻塞主线程。如果内存占用特别大(比如几十 GB),fork 消耗的时间可能达到秒级,这对低延迟业务来说是无法接受的。解决办法是控制单个 Redis 实例的数据量,或者用集群模式把大实例拆成若干小实例。这也是为什么在超大规模缓存场景里,一般不会让单个 Redis 节点堆到几十 GB,而是把内存控制在 4~8GB 以内,宁可多开几个分片,也不要在一个节点上压榨到极致。5. Java 项目落地实践:从依赖到业务代码前面讲完底层原理,这一章直接进入实战。我们用 Spring Boot 生态来做示例,这是目前 Java 后端最主流的接入方式。5.1 项目依赖与基础配置<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>spring-boot-starter-data-redis 默认使用的是 Lettuce 连接池,我们需要在 application.yml 里做基础配置:spring: data: redis: host: 192.168.1.100 port: 6379 timeout: 3s lettuce: pool: max-active: 30 max-idle: 10 min-idle: 5 max-wait: 500ms这里有几个参数值得解释:max-active表示连接池最大活跃连接数,过高会导致 Redis 端文件描述符压力骤增,过低则可能在高峰期出现请求等待;max-wait是获取连接的超时时间,超过这个时间直接抛异常,避免线程无限挂起。对于大多数中小项目,30~50 的 max-active 是合理的起步值。注意:如果你在 Spring Boot 2.x 使用 RedisTemplate,默认的序列化器是 JdkSerializationRedisSerializer,它会把 key 序列化成带有\xAC\xED之类的二进制前缀,这不仅肉眼不可读,还容易导致无法按预期 pattern 删除 key。生产项目强烈建议自定义序列化为 StringRedisSerializer + GenericJackson2JsonRedisSerializer 的组合。5.2 缓存工具类封装与业务代码我用一个简化版的工具类示例,来演示如何写一个带防穿透、防击穿逻辑的缓存读取入口:@Component public class OrderCacheService { @Resource private RedisTemplate<String, Object> redisTemplate; /** * 查询订单详情,带缓存重建策略 */ public Order getOrderById(Long id) { String key = "order:detail:" + id; // 1. 从缓存获取 Object cache = redisTemplate.opsForValue().get(key); if (cache != null) { // 假设缓存中存了空占位对象,用于防穿透 if (cache instanceof EmptyPlaceholder) { return null; } return (Order) cache; } // 2. 获取分布式锁,只让一个线程查库并回填 String lockKey = "lock:order:detail:" + id; String lockValue = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 二次检查,可能另一个线程已经重建了缓存 cache = redisTemplate.opsForValue().get(key); if (cache != null) { return (Order) cache; } // 模拟查库 Order order = queryFromDatabase(id); if (order == null) { // 空值缓存,防止穿透 redisTemplate.opsForValue().set(key, new EmptyPlaceholder(), Duration.ofSeconds(60)); } else { // 加随机过期时间,防止雪崩 long expireSeconds = 600 + ThreadLocalRandom.current().nextInt(120); redisTemplate.opsForValue().set(key, order, Duration.ofSeconds(expireSeconds)); } return order; } finally { // 释放锁:先比较后删除,防止误删别人的锁 String currentValue = (String) redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } } } // 3. 没抢到锁,短暂等待后重新查缓存 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } cache = redisTemplate.opsForValue().get(key); if (cache != null) { return (Order) cache; } // 兜底:直接查库(虽然概率很低) return queryFromDatabase(id); } }这个工具类的思路是:防穿透用空值占位,防击穿用分布式锁 + 二次检查,防雪崩用随机过期时间。它是一个通用模板,完全可以复用到 User、Product 等其他领域的查询场景。5.3 分布式锁的完整闭环:Redis 和 Redisson 的取舍上面代码里的SET key value NX EX是最基础的分布式锁,但实际生产环境我更推荐直接用 Redisson,它内置了看门狗自动续期机制,避免业务执行时间超过锁过期时间导致的并发安全问题。Redisson 的经典用法:@Resource private RedissonClient redissonClient; public void deductStock(Long skuId, Integer count) { RLock lock = redissonClient.getLock("stock:lock:" + skuId); try { // 尝试加锁,最多等 3 秒,10 秒后自动释放 if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 执行库存扣减逻辑 // 注意:这里依然要保证 Redis 与数据库的操作具备幂等性 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }Redisson 的锁底层也是 Lua 脚本,只不过它在客户端封装了续期和重入的逻辑,可靠性比手工实现的 SETNX 方案高不少。但要注意:在使用分布式锁时,一定要考虑锁超时或业务异常导致锁未被正确释放的兜底方案,以及 Redis 主从切换时的锁丢失问题(极端场景可以引入 RedLock,但绝大多数业务不需要这么复杂)。5.4 序列化方案选择与连接池参数的经验值在这一步,我强烈建议你自定义一个 RedisConfig 类,不要直接使用 Spring 默认的 RedisTemplate:@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }注意 GenericJackson2JsonRedisSerializer 在反序列化时需要读取类型信息,所以如果 value 里保存的是带多态的对象,必须在 RedisTemplate 中传入 ObjectMapper 并激活 default typing。这是一个很隐蔽的坑,不少同学上线后才发现反序列化报 ClassCastException。连接池参数我一般按这个起步值来配:max-active 40,max-idle 20,min-idle 5,max-wait 500ms。如果是读多写少的业务,可以考虑把 max-idle 调高一些,减少频繁创建连接的损耗;如果是写多读少的场景,反而要控制 max-active,防止连接池占满导致 Redis 吞吐下降。6. 性能优化与线上事故复盘:那些血泪教训6.1 Big Key:一个超大 key 拖垮整个集群Big Key 指的是单个 key 存储的数据量过大,比如一个 List 有几百万条记录,或者一个 Hash 有几万个 field。它的危害体现在:读取耗时巨大,大 key 的网络传输会占用大量带宽;删除或迁移大 key 时,可能阻塞主线程几秒甚至更久;写放大:每次更新大 key 涉及的 AOF 重写和主从同步数据量都比较大。排查方法很简单:使用redis-cli --bigkeys命令扫描全部 key,它会按类型汇总最大元素数量。一旦发现大 key,解决的思路是拆分或换结构。比如一个存储用户在线状态的 Hash,如果 key 设计成online_users导致 field 数量巨大,不如拆成online_user:{userId}的小 key 或分布式存储。我在实际项目里遇到过一次特别典型的故障:某运营活动模块把单日所有用户的行为明细都塞进了一个 ZSet,高峰期时这个 ZSet 元素超过 300 万,每次运营同学手动查询排行榜都造成 Redis 长时间阻塞,最终表现是接口超时、缓存雪崩连环爆炸。最后我们把日维度数据拆成小时维度,每小时的 ZSet 控制在几十万以内,问题才真正解决。6.2 Hot Key 与流量的碰撞:拆 key 是门手艺Hot Key 指某一瞬间大量请求集中访问同一个 key。即使 Redis 本身性能再高,单 key 的访问也会触及网卡或 CPU 瓶颈,同时还会把底层存储打爆。解决方案的思路一般是复制 key:为热点 key 增加随机后缀,比如seckill:stock:1扩展为seckill:stock:1#0到seckill:stock:1#9十个副本。读取时随机选一个副本,写入时则同步写全部副本(或者用一致性哈希映射到某个副本)。对 Java 项目来说,还有一个常见做法是在应用层加 Caffeine 本地缓存,把热点 key 的数据在 JVM 内存里再存一份,Redis 压力瞬间减少一个量级。本地缓存的问题是一致的,所以只适合读取频繁、时效性要求不太高的数据,比如商品基本信息、配置表等。6.3 慢查询与监控:用什么命令定位问题Redis 提供了慢查询日志,默认阈值 10000 微秒(10ms),可以用CONFIG SET slowlog-log-slower-than 5000把它调低一些,比如 5ms,便于找出慢日志命令:CONFIG SET slowlog-log-slower-than 5000 SLOWLOG GET 20 SLOWLOG LEN慢查询日志只记录执行时间,不会记录网络耗时。如果你想更精确地分析某段时间的命令流量,可以打开monitor命令观察实时命令流。但monitor本身也有副作用,它会降低 Redis 的吞吐,只建议在问题现场短暂开启。有一个非常实用的技巧:在线上尽量全程开启latency monitor功能,设置latency-monitor-threshold 100(毫秒),然后定期用LATENCY GRAPH查看事件耗时。它可以帮助定位 fork、AOF write、内存淘汰等内部操作的异常耗时。7. 从业务角度重新审视 Redis:它不只是中间件7.1 用 Redis 做设计,要提前想清楚"哪些数据该进缓存"很多人把缓存当作一个"加速层",却忽略了缓存引入后带来的数据一致性问题。缓存更新通常有两种策略:先更新数据库再删除缓存(Cache Aside),还是先双删再更新(延迟双删)?没有绝对正确的方案,只有适合业务的方案。我推荐的做法是:把数据库作为唯一数据源,写操作先写 DB,然后删除对应缓存;读操作先读缓存,不命中则读库并写缓存。这样最简单的实现是先把缓存淘汰掉,不存在"更新缓存"的中间态,即使出现并发,最多也只是多查一次库,不会把脏数据持久化在缓存里。对一致性要求更高的业务,比如库存扣减,可以用 Canal 订阅数据库 binlog 异步更新缓存,配合业务幂等,才能真正做到缓存与数据库最终一致。这类方案实现成本高,但收益也直接。7.2 集群模式选型:主从、哨兵还是 Cluster单机 Redis 在性能和可用性上都有上限,生产环境一定不能跑单机。我的建议是:数据量小于 10GB,业务允许秒级故障,用主从 + 哨兵(Sentinel),故障自动切换,成本低;数据量大、需要水平扩展,用 Redis Cluster,把 key 哈希到 16384 个 slot 上,自动分片;如果预算充足且对运维能力不自信,可以直接选择云厂商的托管 Redis。选了 Sentinel 不等于万事大吉。哨兵模式下主从切换有短暂的不可用窗口,同时要考虑 client 侧的连接重连机制;选了 Cluster 则要注意多 key 操作(比如 MGET 跨 slot)在某些配置下会报CROSSSLOT错误,业务代码需要提前规避。7.3 一次真实压测数据让我重新理解了持久化的性价比有一个项目上线前,我用 4 核 8G 的机器做了简单压测:纯读场景下 Redis QPS 大约在 8 万左右;开启 AOF everysec 后降到 6.5 万左右;开启 RDB 快照后对读性能影响不大,但快照期间偶尔会出现毫秒级延迟尖刺。这个数据说明:Redis 的性能高是有条件的。你加了持久化、开了集群、选了复杂的 value 结构,每一样都在"消耗"性能预算。作为架构师,你要做的不是追求极限 QPS,而是找到一个能让业务稳定、数据可恢复、成本可控的组合。性能不是最优解,够用且稳定才是。8. 几条贯穿始终的个人经验最后我分享一下自己长期做 Redis 项目沉淀下来的几条原则。第一,永远不要相信客户端的超时配置能兜住所有问题。Redis 的阻塞往往是从主线程开始的,你的客户端再多重试也叫不醒一个被大 key 阻塞的 Redis 实例。所以一定要做资源隔离:把不重要的业务和核心业务拆到不同 Redis 实例,至少分成缓存 Redis 和业务数据 Redis。第二,上线前必须模拟故障演练。找一个周末低峰期,手动执行 FLUSHALL 或者杀掉主节点,看看你的应用层能不能自愈,缓存重建路径是否顺畅。我见过不少项目,故障发生时才发现缓存重建逻辑写错了,导致雪崩时所有请求都落到数据库,事故被二次放大。第三,关注 Redis 版本升级的收益。Redis 7.x 在内存效率、命令丰富度和多线程 I/O 方面都有实质提升,如果你的项目还在 5.x 或 6.x,可以评估升级的必要性。升级前除了看 release notes,一定要用你真实的业务流量做回归压测,版本跳跃过大时,部分命令行为可能有细微变化。第四,在代码评审时把 Redis 命令当成 SQL 来审。凡是循环里执行 Redis 命令的,都优先改成 pipeline 或 Lua;凡是读出来的数据没有业务意义的,都考虑是不是根本没存必要;凡是缓存 key 没有业务前缀和清晰命名规范的,赶紧定一套规范推下去。这些看起来是小事,但线上事故往往就是从一行"顺手写的遍历"开始的。这篇内容从原理到实践覆盖了我这几年在项目里反复用到的 Redis 核心知识。如果你照着上面的思路搭建一套缓存服务,再在压测环境里验证一遍,我相信你对 Redis 的理解会上一个台阶,也希望下一次 Redis 告警时,你能从容地打开命令窗口,而不是先慌乱地重启服务。
开头我就直说了:Redis 这个东西,凡是做后端开发的,几乎没人敢说自己没用过。但"用过"和"用明白"之间,隔着一条很宽的技术鸿沟。我见过不少项目,Redis 挂了,缓存穿透把数据库打崩了,程序员一脸懵——Redis 不是号称高性能吗?怎么还能出这种事?问题不在于 Redis 本身,而在于使用的人只把 Redis 当成了一个"速度更快的 Map"。这篇文章我想换一个讲法:不按官方文档的目录一项项罗列命令,而是从真实项目会遇到的痛点和问题出发,把 Redis 的核心机制拆开看,再给一套完整的 Java 项目落地实践。无论你是在校学生准备面试,还是工作两三年的后端开发想系统补齐 Redis 短板,这篇文章应该都能让你少走不少弯路。1. 先把底层的"快"和"稳"搞清楚:Redis 的运行模型1.1 单线程模型到底意味着什么很多人第一次接触 Redis,听到的第一句话就是"Redis 是单线程的,所以快"。这句话既对也不对。对的一面在于,Redis 确实把所有命令的执行放在了一个主线程里,避免了多线程锁竞争和上下文切换的开销;不对的一面在于,如果你因此以为 Redis 在多核 CPU 上跑不满、浪费资源,那就误会了。Redis 的单线程是指"处理命令"是串行的,但它同时用了多路复用的 I/O 模型(select / epoll / kqueue 这一层),能够同时监听成千上万个客户端连接。你可以把它这样类比:一个能力超强的服务员,一个人同时接待很多桌客人,每桌点了什么菜他记得清清楚楚,上菜速度极快;但如果某桌客人非要拉着服务员聊十分钟电话,那其他桌全都得等着。这也是为什么 Redis 官方和社区反复强调:千万不要在 Redis 里存大 key 或执行慢命令(比如 KEYS *、对一个百万元素的 Hash 做 HGETALL),因为一旦主线程被阻塞,全站所有请求都会排队,Redis 的整体吞吐瞬间崩塌。1.2 命令执行的串行特性给业务带来的启发单线程串行执行,这本身是一个强大的业务设计工具。很多分布式场景下需要保证原子性的操作,如果依赖数据库锁或者分布式锁,会有一定的网络开销和实现复杂度;但在 Redis 里,因为命令天然是串行执行的,只要把"检查-写入"两步合并到一个 Lua 脚本或一个原子命令里,就能达到类似的效果。举个例子,限制某个用户每分钟最多发 10 条消息。用数据库实现要开事务、加锁;用 Redis 则可以用计数器 + 过期时间的组合,或者直接跑一段 Lua 脚本,在脚本里完成计数校验和自增。由于 Redis 执行脚本期间不允许其他命令插入,整个逻辑是原子且互不干扰的。这也引出一个非常关键的设计原则:能用一条命令完成的,绝不分成多条命令;必须分成多条的,优先考虑 Lua 脚本,而不是在 Java 代码里串行调用。2. 五种数据结构的选型逻辑与应用实战2.1 String 类型的真实利用率比你想的高String 是所有 Redis 类型的基础,本身可以存字符串、数字,也可以存二进制数据(比如小尺寸的图片字节流)。在实际项目中,String 最常见的用途远不止缓存 JSON:第一是计数器:INCR、DECR、INCRBY这类命令天然支持自增自减,原子性由 Redis 保证,可以用来做访问次数统计、库存扣减、消息未读数等场景。注意:如果你的计数需要做持久化,要考虑 RDB 和 AOF 的落盘策略;如果是纯实时指标,放在内存里定期做快照即可。第二是分布式锁的最简实现:SET key value NX EX 5(NX 表示不存在才写入,EX 表示过期时间)。这里建议带上一个唯一标识作为 value,这样删除锁时先比较再删除,避免误删别人的锁。后面第 5 章会给完整的 Java 代码。第三是限流:滑动窗口限流可以用 ZSet 实现,但如果是固定窗口限流,用 String 的SETNX加过期时间就非常方便。这里我想多说一句:使用 String 时,最容易犯的毛病是把 value 存得过大。单个 String 超过 10KB 就要警惕了,尤其是大 JSON。如果确实需要一个对象的多个字段,优先考虑 Hash。2.2 Hash:对象缓存的最佳载体很多新手喜欢把整个对象序列化成 JSON 塞进 String,其实绝大多数场景下,Hash 是更合适的选择。Hash 的每一个 field 可以理解为对象的一个属性。以用户信息为例,你可以把 User 对象的 name、age、avatar 分别存入同一个 key 的各个 field。这样带来的好处是:更新某一个字段时不需要把整个大 JSON 读出来再反序列化再写回去,只需要HSET user:1001 age 26一条命令。尤其是在读多写少的场景下,Hash 的内存效率和更新效率都明显优于 String 存的整块 JSON。底层结构上也值得一提:当 Hash 的字段数量少且字段值不大时,Redis 会使用紧凑的内存编码(6.x 之后逐步演进,方向上是从 ziplist 过渡到 listpack),随着元素增多才转换成 hashtable。这意味着小 Hash 非常节省内存,很多 Redis 内存优化方案都建议用 Hash 来压缩存储批量数据。我个人的建议是:缓存单一实体对象,优先选 Hash;缓存"一个整体且很少改动"的数据,比如配置项、页面静态片段的字符串,才选 String。2.3 List、Set、ZSet 各自的不可替代性List 按照插入顺序存储,左边推入右边弹出,非常适合做消息队列、简单的时间线列表。它的LPUSH+BRPOP模式,可以作为一个可靠度够用的异步队列。对比 RabbitMQ 这类专业 MQ,Redis List 队列少了一些复杂路由和可靠投递语义,但胜在轻量、不需要额外部署中间件。小规模项目里用这个方案,是一件非常务实的做法。Set 的核心价值是去重和集合运算。抽奖、签到、关注列表交集、两个人之间的共同好友这类场景,直接用SADD、SINTER(交集)、SUNION(并集)、SDIFF(差集)就能高效完成。如果不用 Set,用数据库做这些运算不仅 SQL 麻烦,数据量大时性能也很难看。ZSet 是在 Set 的基础上给每个元素加了一个 score(分数),按分数排序。这个数据结构实在太适合排行榜了,比如积分榜只需要ZADD leaderboard 100 userA,然后ZREVRANGE leaderboard 0 99 WITHSCORES就能拿到前 100 名。它还能用来做延迟队列:把任务执行时间戳作为 score,定时用ZRANGEBYSCORE取到期的任务。还有一种用法经常被忽略:ZSet 实现分布式限流的滑动窗口。把每个请求的时间戳塞进 ZSet,score 就是时间戳,滑动窗口检查窗口内元素数量,超出阈值就拒绝。这个方案在实现复杂度和精度之间平衡得很好。2.4 为什么 Redis 7.0 之后的 ZSet 更值得关注ZSet 的底层传统上是"跳跃表 + 字典"的组合,跳跃表保证排序和范围查询的高效,字典保证按成员查分数的 O(1) 速度。在 Redis 7.0 中,官方引入了一个叫 listpack 的新底层结构,替换掉了部分场景中的 ziplist,目的是解决 ziplist 在连锁更新场景下的性能抖动问题。简单说,小数据量时用紧凑内存结构,大数据量时自动升级为跳跃表。这套动态切换机制,是 Redis 能在大数据量下依然保持稳定延迟的重要原因。对业务开发来说,你不需要直接操作这些底层结构,但理解它有助于你判断一个 ZSet 在"元素不多但 score 频繁更新"的场景下是否适合。通常来说,ZSet 的成员数量控制在几十万以内,性能表现都很理想;超过这个量级,建议考虑分片存储或换用专业组件。3. 缓存穿透、击穿、雪崩:防御体系的完整搭建这是面试高频题,也是线上故障的高发区。我把三个问题放在一起讲,因为它们经常被混淆,但本质上发生的原因和应对手段完全不同。3.1 缓存穿透:恶意请求直达数据库的坑穿透指的是:请求的数据在缓存和数据库中都不存在。因为缓存里没有,每次请求都会打到数据库,数据库压力骤增。最典型的是用不存在的用户 ID 连续刷接口,此时缓存永远不可能命中。解决穿透的思路有三个方向:一是接口层的参数校验。对明显不合法的请求(比如 ID 为负数或超大数值)直接拒绝,这是成本最低的第一道防线。二是缓存空值。查数据库发现没有数据,也往 Redis 里写一个空值,过期时间设置短一些,比如 60 秒。这样后续同样的请求就能命中缓存,不会打到数据库。缺点是会缓存大量空 key,要在 key 的设计上做好区分,避免污染正常数据。三是布隆过滤器,这也是我推荐在生产环境使用的方式。布隆过滤器是一个很巧妙的数据结构:它用多个哈希函数把元素映射到一个位数组上,判断"某个 key 是否存在",如果它说"不存在",那一定不存在;如果它说"存在",可能误判(但概率很低,可以通过参数控制)。在查询缓存之前先过一遍布隆过滤器,不存在的 key 直接拦截在入口层,数据库几乎不会收到无效查询。Java 工程里可以用 Redisson 自带的布隆过滤器,也可以用 Google 的 BloomFilter 自行实现。初始化时把已知的合法 ID 集合预先放入,后续新增元素同步加入即可。3.2 缓存击穿:热点 key 过期的瞬间击穿和穿透一字之差,意思却完全不同:击穿指的是某个热点 key 在缓存过期的那一瞬间,大量请求同时打到数据库。数据库单点扛不住,可能直接宕机。最常见的修复方案是互斥锁。当缓存没有命中时,不直接查数据库,而是先尝试获取 Redis 分布式锁;拿到锁的线程去查数据库并回写缓存,其他线程排队等待并重新查缓存。这个方案实现简单,但有个缺点:如果热点 key 重建时间较长,会有一批线程阻塞等待,接口延迟变大。另一个方案是逻辑过期。也就是缓存里的 value 并不设置 Redis 物理过期时间,而是存业务数据和过期时间戳;每次读取时检查逻辑是否过期,如果过期则返回旧数据,同时异步启动一个后台线程更新缓存并替换为新时间戳。这个方案能保证用户请求基本不会被阻塞,但实现复杂度更高,要处理并发下多次触发更新任务的问题,通常配合分布式锁保证只有一个人去更新缓存。我的建议是:如果项目并发量中等(Redis QPS 几千到几万),互斥锁足够;如果某个 key 会被几十万甚至上百万的请求同时打,必须考虑逻辑过期方案。3.3 缓存雪崩:成片 key 同时失效雪崩指的是:大量 key 在同一时间段集中过期,导致请求分散命中到各个 key 时,缓存整体都失效了,数据库流量瞬间增加数倍甚至数十倍。这和击穿很像,但范围不是一个 key,而是一大片 key。最简单的防御手段是给过期时间加随机扰动。比如原本统一过期时间是 10 分钟,改成 10 分钟 ± 1~3 分钟的随机数,这样各个 key 的过期时间就错开了,数据库不会在某一瞬间收到全部回源请求。我见过不少项目,缓存 key 的过期时间写成了一个固定的常量,比如set(key, value, 3600),这种写法在数据量不大时问题不明显,但当你一次性预热了大量 key,或者大量 key 都在同一时间段被写入时,雪崩几乎是必然的。所以请把"过期时间增加随机值"写进自己的编码习惯里。还有一层是多级缓存:热数据放在本地缓存(如 Caffeine)里,Redis 做二级缓存,数据库在最底层。即使 Redis 失效了,本地缓存还能顶住一部分流量,给 Redis 重建赢得时间。3.4 快速区分三类问题的排查口诀穿透是"数据压根不存在",特征是数据库收到的请求全是查不到数据的查询,Redis 中始终没有对应 key;击穿是"单个热点 key 失效",特征是某一瞬间某个 key 的流量冲垮数据库,排查时能看到异常请求集中在同一个 key;雪崩是"大量 key 同时失效",特征是时间上对齐,缓存命中率大幅下降,数据库压力呈阶梯式上升。4. 内存管理与持久化机制:数据不会凭空消失4.1 内存淘汰策略:Redis 内存满了之后会发生什么Redis 的数据存在内存里,这既是它快的原因,也是它的软肋。你必须提前想清楚:当内存写满时,Redis 会怎么做?默认情况下,写入会直接报错(OOM command not allowed when used memory > 'maxmemory'),整个服务虽然活着,但已经无法写入新数据了。在配置文件 redis.conf 中有一项maxmemory-policy,常见的有以下八种策略:策略含义适用场景noeviction内存满时拒绝写入某些不允许丢数据的场景,但会导致写入失败allkeys-lru所有 key 中淘汰最近最少使用的通用缓存场景(最常用)volatile-lru仅对设置了过期时间的 key 淘汰 LRU希望没有过期时间的 key 永不被淘汰allkeys-lfu所有 key 中淘汰最不经常使用的热点分布不均、存在极热门 key 的场景volatile-lfu仅对设置了过期时间的 key 淘汰 LFU同上,但保护未过期 keyallkeys-random随机淘汰所有 key数据分布均匀、无需优先级的场景volatile-random随机淘汰设置了过期时间的 key极少使用volatile-ttl淘汰剩余存活时间最短的 key希望尽快腾出空间的场景生产环境我建议优先考虑allkeys-lru或allkeys-lfu。如果你的业务有明确的冷热数据区分,LFU 通常比 LRU 更能抵抗"偶发扫库"类操作对热数据的覆盖。具体怎么选,要结合业务特征,但别选 noeviction,除非你确定内存永远不会被打满。4.2 持久化:RDB 和 AOF 到底怎么取舍Redis 虽然叫缓存,但很多人把它当数据库用,存储的数据具有一定的资产属性,这时候你不能只依赖内存,必须开启持久化。持久化就涉及两个机制:RDB(快照):按照配置的时间间隔(比如 900 秒内有 1 次修改,或 300 秒内有 10 次修改)生成整个数据集的二进制快照文件。优点是文件紧凑、恢复速度快;缺点是两次快照之间的数据可能丢失。AOF(追加日志):记录每一条写命令,追加到文件中。优点是数据安全性高,可以把丢失范围控制在几秒甚至一条命令内;缺点是文件体积大,恢复速度比 RDB 慢。AOF 有三种刷盘策略:always(每条命令都刷盘,最安全但最慢)、everysec(每秒刷一次,推荐)、no(交给操作系统决定,安全性最低)。组合使用时,官方现在的默认做法是打开appendonly yes,并开启aof-use-rdb-preamble yes——AOF 文件头部可以是一个 RDB 格式的二进制快照,后面再追加增量命令。这样既保证了数据恢复速度,又保证了增量数据的安全性。企业在重启或迁移时,通常还会配合redis-check-aof --fix做完整性检查。4.3 一个容易踩的坑:持久化也要考虑 fork 阻塞不管选择哪种持久化方案,在生成快照或重写 AOF 时,Redis 会 fork 出一个子进程,而 fork 这个动作本身会短暂阻塞主线程。如果内存占用特别大(比如几十 GB),fork 消耗的时间可能达到秒级,这对低延迟业务来说是无法接受的。解决办法是控制单个 Redis 实例的数据量,或者用集群模式把大实例拆成若干小实例。这也是为什么在超大规模缓存场景里,一般不会让单个 Redis 节点堆到几十 GB,而是把内存控制在 4~8GB 以内,宁可多开几个分片,也不要在一个节点上压榨到极致。5. Java 项目落地实践:从依赖到业务代码前面讲完底层原理,这一章直接进入实战。我们用 Spring Boot 生态来做示例,这是目前 Java 后端最主流的接入方式。5.1 项目依赖与基础配置<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>spring-boot-starter-data-redis 默认使用的是 Lettuce 连接池,我们需要在 application.yml 里做基础配置:spring: data: redis: host: 192.168.1.100 port: 6379 timeout: 3s lettuce: pool: max-active: 30 max-idle: 10 min-idle: 5 max-wait: 500ms这里有几个参数值得解释:max-active表示连接池最大活跃连接数,过高会导致 Redis 端文件描述符压力骤增,过低则可能在高峰期出现请求等待;max-wait是获取连接的超时时间,超过这个时间直接抛异常,避免线程无限挂起。对于大多数中小项目,30~50 的 max-active 是合理的起步值。注意:如果你在 Spring Boot 2.x 使用 RedisTemplate,默认的序列化器是 JdkSerializationRedisSerializer,它会把 key 序列化成带有\xAC\xED之类的二进制前缀,这不仅肉眼不可读,还容易导致无法按预期 pattern 删除 key。生产项目强烈建议自定义序列化为 StringRedisSerializer + GenericJackson2JsonRedisSerializer 的组合。5.2 缓存工具类封装与业务代码我用一个简化版的工具类示例,来演示如何写一个带防穿透、防击穿逻辑的缓存读取入口:@Component public class OrderCacheService { @Resource private RedisTemplate<String, Object> redisTemplate; /** * 查询订单详情,带缓存重建策略 */ public Order getOrderById(Long id) { String key = "order:detail:" + id; // 1. 从缓存获取 Object cache = redisTemplate.opsForValue().get(key); if (cache != null) { // 假设缓存中存了空占位对象,用于防穿透 if (cache instanceof EmptyPlaceholder) { return null; } return (Order) cache; } // 2. 获取分布式锁,只让一个线程查库并回填 String lockKey = "lock:order:detail:" + id; String lockValue = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 二次检查,可能另一个线程已经重建了缓存 cache = redisTemplate.opsForValue().get(key); if (cache != null) { return (Order) cache; } // 模拟查库 Order order = queryFromDatabase(id); if (order == null) { // 空值缓存,防止穿透 redisTemplate.opsForValue().set(key, new EmptyPlaceholder(), Duration.ofSeconds(60)); } else { // 加随机过期时间,防止雪崩 long expireSeconds = 600 + ThreadLocalRandom.current().nextInt(120); redisTemplate.opsForValue().set(key, order, Duration.ofSeconds(expireSeconds)); } return order; } finally { // 释放锁:先比较后删除,防止误删别人的锁 String currentValue = (String) redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } } } // 3. 没抢到锁,短暂等待后重新查缓存 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } cache = redisTemplate.opsForValue().get(key); if (cache != null) { return (Order) cache; } // 兜底:直接查库(虽然概率很低) return queryFromDatabase(id); } }这个工具类的思路是:防穿透用空值占位,防击穿用分布式锁 + 二次检查,防雪崩用随机过期时间。它是一个通用模板,完全可以复用到 User、Product 等其他领域的查询场景。5.3 分布式锁的完整闭环:Redis 和 Redisson 的取舍上面代码里的SET key value NX EX是最基础的分布式锁,但实际生产环境我更推荐直接用 Redisson,它内置了看门狗自动续期机制,避免业务执行时间超过锁过期时间导致的并发安全问题。Redisson 的经典用法:@Resource private RedissonClient redissonClient; public void deductStock(Long skuId, Integer count) { RLock lock = redissonClient.getLock("stock:lock:" + skuId); try { // 尝试加锁,最多等 3 秒,10 秒后自动释放 if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 执行库存扣减逻辑 // 注意:这里依然要保证 Redis 与数据库的操作具备幂等性 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }Redisson 的锁底层也是 Lua 脚本,只不过它在客户端封装了续期和重入的逻辑,可靠性比手工实现的 SETNX 方案高不少。但要注意:在使用分布式锁时,一定要考虑锁超时或业务异常导致锁未被正确释放的兜底方案,以及 Redis 主从切换时的锁丢失问题(极端场景可以引入 RedLock,但绝大多数业务不需要这么复杂)。5.4 序列化方案选择与连接池参数的经验值在这一步,我强烈建议你自定义一个 RedisConfig 类,不要直接使用 Spring 默认的 RedisTemplate:@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }注意 GenericJackson2JsonRedisSerializer 在反序列化时需要读取类型信息,所以如果 value 里保存的是带多态的对象,必须在 RedisTemplate 中传入 ObjectMapper 并激活 default typing。这是一个很隐蔽的坑,不少同学上线后才发现反序列化报 ClassCastException。连接池参数我一般按这个起步值来配:max-active 40,max-idle 20,min-idle 5,max-wait 500ms。如果是读多写少的业务,可以考虑把 max-idle 调高一些,减少频繁创建连接的损耗;如果是写多读少的场景,反而要控制 max-active,防止连接池占满导致 Redis 吞吐下降。6. 性能优化与线上事故复盘:那些血泪教训6.1 Big Key:一个超大 key 拖垮整个集群Big Key 指的是单个 key 存储的数据量过大,比如一个 List 有几百万条记录,或者一个 Hash 有几万个 field。它的危害体现在:读取耗时巨大,大 key 的网络传输会占用大量带宽;删除或迁移大 key 时,可能阻塞主线程几秒甚至更久;写放大:每次更新大 key 涉及的 AOF 重写和主从同步数据量都比较大。排查方法很简单:使用redis-cli --bigkeys命令扫描全部 key,它会按类型汇总最大元素数量。一旦发现大 key,解决的思路是拆分或换结构。比如一个存储用户在线状态的 Hash,如果 key 设计成online_users导致 field 数量巨大,不如拆成online_user:{userId}的小 key 或分布式存储。我在实际项目里遇到过一次特别典型的故障:某运营活动模块把单日所有用户的行为明细都塞进了一个 ZSet,高峰期时这个 ZSet 元素超过 300 万,每次运营同学手动查询排行榜都造成 Redis 长时间阻塞,最终表现是接口超时、缓存雪崩连环爆炸。最后我们把日维度数据拆成小时维度,每小时的 ZSet 控制在几十万以内,问题才真正解决。6.2 Hot Key 与流量的碰撞:拆 key 是门手艺Hot Key 指某一瞬间大量请求集中访问同一个 key。即使 Redis 本身性能再高,单 key 的访问也会触及网卡或 CPU 瓶颈,同时还会把底层存储打爆。解决方案的思路一般是复制 key:为热点 key 增加随机后缀,比如seckill:stock:1扩展为seckill:stock:1#0到seckill:stock:1#9十个副本。读取时随机选一个副本,写入时则同步写全部副本(或者用一致性哈希映射到某个副本)。对 Java 项目来说,还有一个常见做法是在应用层加 Caffeine 本地缓存,把热点 key 的数据在 JVM 内存里再存一份,Redis 压力瞬间减少一个量级。本地缓存的问题是一致的,所以只适合读取频繁、时效性要求不太高的数据,比如商品基本信息、配置表等。6.3 慢查询与监控:用什么命令定位问题Redis 提供了慢查询日志,默认阈值 10000 微秒(10ms),可以用CONFIG SET slowlog-log-slower-than 5000把它调低一些,比如 5ms,便于找出慢日志命令:CONFIG SET slowlog-log-slower-than 5000 SLOWLOG GET 20 SLOWLOG LEN慢查询日志只记录执行时间,不会记录网络耗时。如果你想更精确地分析某段时间的命令流量,可以打开monitor命令观察实时命令流。但monitor本身也有副作用,它会降低 Redis 的吞吐,只建议在问题现场短暂开启。有一个非常实用的技巧:在线上尽量全程开启latency monitor功能,设置latency-monitor-threshold 100(毫秒),然后定期用LATENCY GRAPH查看事件耗时。它可以帮助定位 fork、AOF write、内存淘汰等内部操作的异常耗时。7. 从业务角度重新审视 Redis:它不只是中间件7.1 用 Redis 做设计,要提前想清楚"哪些数据该进缓存"很多人把缓存当作一个"加速层",却忽略了缓存引入后带来的数据一致性问题。缓存更新通常有两种策略:先更新数据库再删除缓存(Cache Aside),还是先双删再更新(延迟双删)?没有绝对正确的方案,只有适合业务的方案。我推荐的做法是:把数据库作为唯一数据源,写操作先写 DB,然后删除对应缓存;读操作先读缓存,不命中则读库并写缓存。这样最简单的实现是先把缓存淘汰掉,不存在"更新缓存"的中间态,即使出现并发,最多也只是多查一次库,不会把脏数据持久化在缓存里。对一致性要求更高的业务,比如库存扣减,可以用 Canal 订阅数据库 binlog 异步更新缓存,配合业务幂等,才能真正做到缓存与数据库最终一致。这类方案实现成本高,但收益也直接。7.2 集群模式选型:主从、哨兵还是 Cluster单机 Redis 在性能和可用性上都有上限,生产环境一定不能跑单机。我的建议是:数据量小于 10GB,业务允许秒级故障,用主从 + 哨兵(Sentinel),故障自动切换,成本低;数据量大、需要水平扩展,用 Redis Cluster,把 key 哈希到 16384 个 slot 上,自动分片;如果预算充足且对运维能力不自信,可以直接选择云厂商的托管 Redis。选了 Sentinel 不等于万事大吉。哨兵模式下主从切换有短暂的不可用窗口,同时要考虑 client 侧的连接重连机制;选了 Cluster 则要注意多 key 操作(比如 MGET 跨 slot)在某些配置下会报CROSSSLOT错误,业务代码需要提前规避。7.3 一次真实压测数据让我重新理解了持久化的性价比有一个项目上线前,我用 4 核 8G 的机器做了简单压测:纯读场景下 Redis QPS 大约在 8 万左右;开启 AOF everysec 后降到 6.5 万左右;开启 RDB 快照后对读性能影响不大,但快照期间偶尔会出现毫秒级延迟尖刺。这个数据说明:Redis 的性能高是有条件的。你加了持久化、开了集群、选了复杂的 value 结构,每一样都在"消耗"性能预算。作为架构师,你要做的不是追求极限 QPS,而是找到一个能让业务稳定、数据可恢复、成本可控的组合。性能不是最优解,够用且稳定才是。8. 几条贯穿始终的个人经验最后我分享一下自己长期做 Redis 项目沉淀下来的几条原则。第一,永远不要相信客户端的超时配置能兜住所有问题。Redis 的阻塞往往是从主线程开始的,你的客户端再多重试也叫不醒一个被大 key 阻塞的 Redis 实例。所以一定要做资源隔离:把不重要的业务和核心业务拆到不同 Redis 实例,至少分成缓存 Redis 和业务数据 Redis。第二,上线前必须模拟故障演练。找一个周末低峰期,手动执行 FLUSHALL 或者杀掉主节点,看看你的应用层能不能自愈,缓存重建路径是否顺畅。我见过不少项目,故障发生时才发现缓存重建逻辑写错了,导致雪崩时所有请求都落到数据库,事故被二次放大。第三,关注 Redis 版本升级的收益。Redis 7.x 在内存效率、命令丰富度和多线程 I/O 方面都有实质提升,如果你的项目还在 5.x 或 6.x,可以评估升级的必要性。升级前除了看 release notes,一定要用你真实的业务流量做回归压测,版本跳跃过大时,部分命令行为可能有细微变化。第四,在代码评审时把 Redis 命令当成 SQL 来审。凡是循环里执行 Redis 命令的,都优先改成 pipeline 或 Lua;凡是读出来的数据没有业务意义的,都考虑是不是根本没存必要;凡是缓存 key 没有业务前缀和清晰命名规范的,赶紧定一套规范推下去。这些看起来是小事,但线上事故往往就是从一行"顺手写的遍历"开始的。这篇内容从原理到实践覆盖了我这几年在项目里反复用到的 Redis 核心知识。如果你照着上面的思路搭建一套缓存服务,再在压测环境里验证一遍,我相信你对 Redis 的理解会上一个台阶,也希望下一次 Redis 告警时,你能从容地打开命令窗口,而不是先慌乱地重启服务。