
Redis这玩意儿我前后用了快十年。从最早只是拿它做某个后台模块的本地缓存到后来在微服务架构里当分布式锁、扛排行榜、处理延迟任务一路踩过的坑确实不少。一开始我也觉得它无非就是个厉害点的HashMap但用久了才意识到Redis真正的价值不只是“快”而是它自带的那套丰富数据结构能帮你把一堆原本要绕很多弯、写很多代码的问题用几条命令就干净利落地解决掉。这篇文章把我日常项目里高频出现的15种Redis使用场景整理出来。每个场景我都会讲清楚它到底解决什么问题、底层怎么运作、实际应用中容易踩哪些坑。内容偏实操适合正在做后端开发、准备做架构设计或者最近在刷Redis面试题的朋友。即便是刚接触Redis的新人照着里面的思路和命令去试也能很快把Redis从“会用”变成“用得稳”。1. 缓存类场景让后端真正“快起来”1.1 场景一缓存穿透用空对象和布隆过滤器兜底缓存穿透形容的是这样一个问题客户端疯狂请求一个在数据库里根本不存在的key比如传一个不存在的用户ID。第一次查缓存发现没有于是穿透到数据库数据库里也查不到然后这个key根本不会被写入缓存。结果就是每个请求都会直接打到数据库流量一大DB就扛不住了。我在生产环境处理这个问题的第一道防线是给“查不到”的结果也做一层缓存。数据库查询返回空就向Redis写入一个空值并设置短期TTL比如60秒SET user:info:99999 EX 60这样后续对同一个不存在key的请求会在Redis这一层直接拿到空结果不会再去数据库走一遍。不过空对象缓存有两个毛病。第一恶意请求如果伪造大量随机ID每个ID都写一条空缓存Redis内存会白白被吃掉不少。第二业务上“不存在”和“系统错误”如果都返回空容易被其他模块误判。所以更推荐的方式是前置一层布隆过滤器Bloom Filter在请求进入Redis之前直接用过滤器判断“这个key是否大概率存在”。过滤器说不存在直接返回过滤器说存在再走缓存DB的流程。Redis官方模块RedisBloom提供了现成命令BF.RESERVE user_filter 0.01 100000 BF.ADD user_filter user:99999 BF.EXISTS user_filter user:999990.01表示误判率100000表示预期容量。布隆过滤器有个特点它只会误判“存在”不会误判“不存在”所以在缓存穿透场景下使用非常合适。注意布隆过滤器是用小概率误判换内存占用。别把容量设得太小否则误判率会飙升过滤效果大打折扣。1.2 场景二缓存击穿热点key过期的并发风暴缓存穿透是“查不存在的内容”缓存击穿则是“查存在但刚好过期的热点内容”。一个非常热的key比如某款秒杀商品的详情数据正常情况下都命中了缓存。但就在它过期的那一瞬间成千上万个请求同时发现缓存没有又同时打到数据库DB瞬间就顶不住了。处理击穿的思路有两个方向。第一个方向是互斥锁。用Redis的SETNX命令实现当某个热点key的缓存为null时不是所有请求都去数据库查而是先尝试获取一把分布式锁。拿到锁的线程去数据库查询并回写缓存其他线程短暂自旋等待后再次读取缓存SET lock:product:10001 unique_request_id NX PX 10000如果设置成功说明拿到了锁执行业务回源。设置失败就等几十毫秒后重新走一遍查询流程。这里必须加过期时间防止拿到锁的线程突然宕机导致死锁。第二个方向是逻辑过期。value里存的不再是纯粹的业务数据而是“数据过期时间点”。请求到来后发现逻辑时间已过期不会立刻删除缓存而是让一个线程去数据库更新数据其余线程继续读旧数据。这种方式用户体验更好但实现上要处理好异步回源和写线程冲突的细节。实操心得热点key的过期时间千万别设成固定值。比如你缓存了三分钟的秒杀商品三分钟后肯定会有一次集中过期请求。给过期时间加一个随机偏移比如180秒到300秒之间随机能很有效地降低集体失效的风险。1.3 场景三缓存雪崩大量key同时失效或Redis彻底不可用雪崩比穿透和击穿更可怕。它通常指两种情况一种是缓存层里大量key在同一时间段集中过期导致请求大面积穿透到数据库另一种是Redis服务本身挂了所有请求直接打到数据库整个后端被压垮。针对第一种情况核心手段是过期时间随机化。批量写入缓存时最好给TTL加一个随机数。比如基础过期时间是300秒那实际写入时在300到600秒之间随机选一个值。这样积压到同一时刻过期的概率会大幅降低。针对第二种情况单靠Redis自身不够需要做高可用部署。最经典的是主从复制加哨兵模式主节点负责读写从节点负责备份和故障转移。主节点挂了之后哨兵会自动提升某个从节点成为新主节点应用侧感知不到明显中断。更精细一点的可以在业务侧增加本地缓存兜底Redis不可用时短暂返回本地旧数据给故障恢复争取时间。如果是用Docker Compose部署生产环境Redis我一般建议至少开一个主节点加两个从节点再配三个哨兵实例。etcd、consul之类的动态服务发现如果能集成应用配置里的Redis地址就可以做成动态感知故障切换后客户端自动重新连接新的主节点这也是很多公司在高并发场景下的标配做法。1.4 场景四缓存与数据库双写一致性延时双删与最终一致写缓存和写数据库谁先谁后这个问题争论了很多年。我实践下来的结论是日常业务中不要试图去做强一致能把“最终一致”做好已经能解决绝大多数问题。最常用的是Cache Aside模式。读的时候先读缓存缓存没有就读数据库然后回写缓存。写的时候先更新数据库再删除缓存。这里很多人会问为什么不更新缓存而是删除缓存因为频繁的更新操作可能让缓存里的数据还没被读就走废了删除是懒加载下一次读的时候再回写更省资源。“先更新数据库再删除缓存”也会有一个坑如果删除缓存失败缓存里留的还是旧数据。解决手段是延时双删。先删除缓存再更新数据库稍微休眠几百毫秒后再次删除缓存。这个二次删除主要对付并发请求期间某个线程读到了旧数据并且重新写回缓存的“脏写”窗口期。具体延时值要根据业务读取速度来定一般500ms到1000ms足够了。还能更彻底一点用一个后台任务监听数据库的binlog变更事件数据发生变化时主动删除对应缓存。这样业务代码不用纠缠删除时机数据库变更和缓存清理通过消息异步解耦消息队列负责保证这个动作一定被执行。这个方案实现起来略重但可靠性明显更高。有个别场景是需要强一致的比如金额、库存之类的敏感数据。这时候我一般不推荐用Redis做主存储而是把数据库当唯一事实源用事务保证最终一致性Redis只承担读加速的职责。2. 分布式协作类场景多实例协调的“内存中间人”2.1 场景五分布式锁解决并发抢占的问题在单体应用里多线程竞争共享资源可以用synchronized或者ReentrantLock。但服务拆成多个实例之后A实例和B实例同时操作同一个订单状态本地的锁已经管不到对方了。这时候就需要一把全局锁Redis天然适合干这个活。最简单的分布式锁就是SETNX。但光有SETNX是不够的业界普遍使用的是带唯一请求ID和过期时间的原子命令SET lock:order:10001 req_001 NX PX 30000解锁时不能直接DEL因为有可能当前线程自己的锁已经过期另一个线程拿到了同一把锁的新的锁权你贸然删除会把自己的锁释放掉别人的锁。所以解锁操作一定要校验唯一ID并且把校验和删除放在同一个Lua脚本里保证原子性if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end生产环境我更推荐直接用Redisson框架。它封装好了看门狗续期机制一个线程拿到锁之后如果业务还没执行完看门狗会自动给锁续期避免业务没跑完锁就过期了。用Java写起来基本上是RLock lock redissonClient.getLock(order: orderId); lock.lock(30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { lock.unlock(); }这里要留个心分布式锁能解决大多数互斥需求但如果业务对安全性要求极高比如防重复扣款可能还需要配合数据库唯一索引或状态机做最终兜底不能把所有希望都押在一把Redis锁上。2.2 场景六分布式Session共享解决多实例登录态丢失传统单体场景Session存在应用服务器的内存里。到了分布式部署用户第一次请求落在A机器第二次请求被负载均衡转发到B机器如果Session不在B机器上用户就会被判定为未登录。解决思路是把Session数据从单机内存搬到集中存储而Redis就是最常用的存储载体。Spring Boot生态里用Spring Session Data Redis非常方便引入依赖并配置后HttpSession的实现自动切换为Redis存储dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency配置好Redis连接之后项目里写的session.setAttribute、session.getAttribute底层都由Redis完成前端Cookie里的Session ID仍然保持不变。这个方案最大的优势是业务代码几乎零侵入只要注意序列化方式。说到序列化就不得不提一个常见坑。Spring Session默认的序列化器是JDK序列化它会把数据以Java二进制形式写进Redis看起来就是一堆乱码排查问题时非常痛苦而且跨语言基本没法读。所以建议在配置里显式替换成JSON序列化Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }这样做还有个附带好处如果未来需要把Session数据交给数据分析团队或运维调试他们可以直接从Redis里读出明文信息。2.3 场景七轻量消息队列用List实现可靠任务分发很多团队一提到消息队列就上Kafka或者RabbitMQ这个选择没错但如果你只是想要一个轻量级的任务队列不想引入额外的中间件负担Redis的List结构完全可以胜任。List自带LPUSH和BRPOP两个命令。LPUSH从队列左侧写入消息BRPOP从右侧阻塞读取消息。阻塞读取保证了没有消息时消费者不会空轮询而是挂起等待节省CPULPUSH task:queue {json payload} BRPOP task:queue 0在可靠性方面可以用BRPOPLPUSH做消息确认。读消息的同时把消息备份到一个处理中队列等业务处理成功后主动清除备份如果消费者处理中途宕机备份队列里的消息还能被重新捞起来处理。这个机制虽然简单但比纯LPUSH/BRPOP多了一层可恢复能力。当然这种“列表型队列”的局限性也很明显。它没有真正的消费者组概念多个消费者同时BRPOP时消息会分发给其中一个消费者不会广播。做不到分区有序、回溯消费等高级语义。所以我的建议是任务量少、逻辑简单、不需要复杂路由的轻量梯队用Redis没问题真正核心的异步消息链路还是上专业的消息队列更稳妥。还有一个泄露给新手的细节批量投递消息时最好用LPUSH一次塞一个数组减少网络RTT消费者端可以用LRANGE加LTRIM实现批量获取并裁剪吞吐量比一条一条读要高不少。2.4 场景八延迟队列ZSet的自然时间轴延迟队列在生活中最常见的场景是电商订单超时未支付自动关单。常规做法是定时任务扫描数据库里的待支付订单但订单量大了之后频繁全表扫描非常浪费。用Redis的ZSet结构做延迟队列可以把扫描成本降得很低。核心思路是用到期时间作为score业务数据作为valueZADD order:delay 1750000000 order_id_10001后台启动一个守护任务每隔一段时间执行一次ZRANGEBYSCORE查询把所有score小于等于当前时间戳的任务取出来然后逐条ZREM删除并执行业务处理ZRANGEBYSCORE order:delay 0 now_timestamp ZREM order:delay order_id_10001这个方案实现简单但有一个注意点轮询间隔越大任务执行的实时性越差轮询间隔太小又会有不少空轮询消耗CPU。我一般用两个手段来优化一是轮询间隔控制在1秒左右对订单关闭这种场景绰绰有余二是业务处理优先级高的任务单独放一个更小的ZSet用更短的间隔轮询避免被大量普通任务挡住。如果不想自己造轮子也可以直接用Redisson提供的RDelayedQueue底层还是基于ZSet但封装了定时推送逻辑Java项目用起来更顺手。延迟队列从原理到落地都不复杂但架构设计中经常能派上大用场。3. 数据结构衍生类场景把每种类型用到位3.1 场景九排行榜ZSet一出手就知有没有排行榜是我最喜欢跟人聊的Redis案例因为用ZSet来做代码量少到让人感动。ZSet内部是跳表实现的每一个元素都带一个score天然支持按分数排序。比如某直播平台的礼物热榜用户每收到一枚火箭就往对应主播的score上加对应分数ZINCRBY live:gift:rank 1000 anchor_888需要读取Top N的时候ZREVRANGE live:gift:rank 0 9 WITHSCORESZREVRANGE是按分数从高到低取ZRANGE是按分数从低到高取。分页读取榜单也特别自然无非是调整start和stop偏移量这正好对应热词里的“redis分页”用法ZREVRANGE live:gift:rank 10 19 WITHSCORES真正实现的时候大多数人会忽略一个细节排行榜同分怎么排名次。ZSet是按score再按字典序排序如果希望同分情况下按业务规则排序可以把score改造成“业务分数业务计数字段”的组合数值。比如实际分数是积分可以用浮点数把小数的后几位作为参与排行的业务参数比如“1000.0000001”表示积分为1000且携带权重虽然有点tricky但很实用。3.2 场景十计数器与接口限流INCR的高频拿手好戏Redis的INCR、INCRBY命令是原子性的天生适合做各种计数器。比如统计文章阅读量每次点击执行一次INCR相比更新数据库字段Redis的写性能高几个量级。每天定时把这个计数同步到数据库做持久化即可。计数器之外INCR还可以用来做接口限流。最简单的固定窗口限流INCR api:limit:user_10001 EXPIRE api:limit:user_10001 60先INCR如果返回值大于设定的阈值就拒绝请求。但EXPIRE和INCR之间可能存在原子性窗口稳妥做法是把判断和设置过期时间放在一个Lua脚本里local num redis.call(INCR, KEYS[1]) if num 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end if num tonumber(ARGV[2]) then return 0 end return 1固定窗口限流有一个边界问题窗口切换的一瞬间可能出现双倍请求。比如前59秒都没人访问最后一秒涌入100个请求下一个窗口又涌入100个实际1秒内的请求可能超过阈值。更平滑的是滑动窗口限流用ZSet记录每个请求的时间戳判定时间窗口内的请求数量ZREMRANGEBYSCORE api:sliding:user_10001 0 (now - 60s) ZCARD api:sliding:user_10001如果ZCARD大于阈值就拒绝新请求。每次请求时先ZADD一个当前时间戳再执行清理和统计。这个方案判断更准确但内存消耗会随着请求量线性增长适合中等并发规模。3.3 场景十一集合运算与去重Set的社交生态Set结构支持交集、并集、差集运算这让它成了社交类功能的常客。比如“查看我和某个好友的共同关注”对两个Set执行SINTER即可SINTER user:10001:follow user:10002:follow拉黑名单、抽奖去重、投票去重这些需求也都适合用Set。抽奖时直接把所有参与者的user_id加进Set然后用SRANDMEMBER随机取出不重复的中奖人避免了数据库去重的复杂SQLSADD lottery:20241001 user_10001 SRANDMEMBER lottery:20241001 3Set做UV统计有一个问题用户量极大时内存占用明显。如果只求UV的近似值可以用HyperLogLog结构。它把每个元素通过哈希映射到bit数组中PFADD一条命令PFCOUNT一下就能拿到亿级数据量下的近似去重统计误差在0.81%左右内存消耗极小PFADD page:uv:20241001 user_10001 user_10002 PFCOUNT page:uv:20241001对于首页PV/UV这种对精度要求不高的运营指标HyperLogLog简直是神器。3.4 场景十二位图Bitmap签到和在线状态的“空间魔术”Bitmap不是Redis的一种独立数据类型它是String类型的位操作模式。一个用户ID对应一个bit位日常可以用SETBIT和BITCOUNT完成海量用户的布尔状态标记。最典型的应用是签到。假设user_id为10001的用户第1天签到第3天签到第5天签到可以这样记录SETBIT sign:20241001 10001 1但用户签到一般是按“用户维度月份”来存的key可以是sign:10001:202410bit位代表日期。这样判断某个用户本月累计签到天数一个BITCOUNT就出来了BITCOUNT sign:10001:202410在线状态也是类似思路。用户登录时SETBIT online:20241001 userId 1心跳时确认在线每天凌晨或者固定周期把所有bit清零就能快速统计日活和实时在线数。BITCOUNT命令对亿级bit位也能在毫秒级返回结果速度非常可观。稀疏数据场景下Bitmap可能浪费内存。比如总共一亿个用户实际每天在线的只有几千人那么一个1.25MB的bit数组大部分都是0。这种情况下可以考虑用分段Bitmap按用户ID范围拆成多个key只在用户登录时才去SETBIT对应段。3.5 场景十三分页列表与时间线List裁剪玩出花样List结构是按插入顺序排列的非常适合做“最新动态”时间线。某个用户发表了新动态就LPUSH到它的feed列表读取时LRANGE取前20条LPUSH user:10001:feed {feed_id:1, content:...} LRANGE user:10001:feed 0 19如果只保留最近500条执行一次LTRIM裁剪即可LTRIM user:10001:feed 0 499这样列表永远只保留最新数据内存有界。这个特性用来做首页feed流、操作日志、站内信列表都非常合适。分页的两种姿势在这类需求里也经常对比一种是基于偏移量的LRANGE分页适合数据总量稳定且不频繁变动的场景另一种是基于游标的增量分页每次取完记录最后一条的ID下一次从这条ID之后继续取适合数据持续增长的场景。列表结构的LRANGE天然支持偏移量分页但如果数据量到了百万级还是建议用游标方式避免下一次LRANGE重新扫描大量已读数据。4. 进阶场景与生产落地从“能用”到“用得稳”4.1 场景十四Geo地理位置附近的人和门店搜索Redis的Geo功能其实是ZSet的封装底层把经纬度通过GeoHash编码成一个一维分数然后复用ZSet的排序能力。最常用的几个命令GEOADD store:geo 116.397128 39.916527 store_001 GEOSEARCH store:geo FROMLONLAT 116.40 39.90 BYRADIUS 5 km ASCGEOSEARCH能直接查出一个坐标点周围5公里内的所有门店并且按距离从近到远排列。这个能力用在“附近的人”“附近的门店”“配送范围判断”上都很顺手。Geo有个使用细节GeoHash编码后的经纬度距离是近似值它适合做圈选和排序但如果你要做精确到米级的距离判断建议还是用GEODIST计算两个点之间的实际球面距离而不要通过Geo排名反推。另外更新一个地理位置的score本质上就是更新ZSet的score直接GEOADD同一个member多次即可删除则用ZREM命令。4.2 场景十五唯一ID生成与海量去重的布隆过滤器很多团队的全局主键ID会用雪花算法但有些场景下利用Redis的INCR生成趋势递增ID反而更合适比如订单号、流水号。Redis单线程模型保证了INCR天然原子多个实例同时调用不会出现重复。为了减少对Redis的依赖客户端可以一次批量取一段ID区间在本地逐步分发INCRBY global:id:order 1000拿到第1000号本地从1001用到2000用完再向Redis取下一段。这样即使Redis短暂抖动本地也有存量ID可用。布隆过滤器的场景在前面缓存穿透里提过它也可以独立用来做海量数据的去重。比如爬虫服务判断一个URL是否已经被抓取过、新闻推荐判断一篇文章是否已经推送给用户。几十亿级别的数据用布隆过滤器也就几百MB内存比维护一个全量Set节省太多。RedisBloom模块提供的BF.ADD和BF.EXISTS使用起来非常简单也可以在客户端直接集成guava的BloomFilter序列化后存入Redis。4.3 生产环境落地要点持久化、主从哨兵、可视化管理最后聊点基础设施层面的东西。只要你把Redis用到生产环境持久化就是一个绕不开的话题。Redis提供了RDB快照和AOF日志两种机制。RDB恢复速度快但会有丢数据窗口AOF记录每次写命令可靠性强但文件膨胀后恢复慢。我目前的实践是既要RDB做定期全量快照AOF开启everysec同步把崩溃丢失窗口控制在1秒内。新版本的Redis已经默认开启了AOF混合持久化兼顾了RDB的恢复速度和AOF的数据完整性默认配置直接用就挺好。主从复制和哨兵模式是生产环境高可用最常见的一对组合。至少一主两从加三个哨兵实例哨兵负责心跳检测和故障转移。需要注意的是主从复制的数据同步是异步的主节点刚写入一条数据还没同步到从节点结果主节点宕机了这部分数据会丢。所以在设计缓存代码时一定要考虑“从主节点读、从节点同步不保证实时”的特性不能把重要业务数据在临界时刻依赖从节点读取。可视化客户端方面我个人比较推荐Another Redis Desktop Manager。相比老牌的Redis Desktop Manager它社区版免费并发连接管理也稳定。我在排查线上问题时用得最多的是它的命令行窗口可以快速执行KEYS或者SCAN命令看数据结构查看某个key的TTL、内存占用以及序列化后的具体值。生产库上千万别随意执行KEYS *数据量大时会阻塞Redis单线程模型导致服务卡顿要用SCAN命令代替。最后再分享一个小技巧踩过几次坑之后我现在写缓存代码都会习惯性加一个全局的缓存Key前缀规范。比如user模块是user:cache订单模块是order:cache线上环境通过前缀就能反推业务归属排查问题时ARDB的树形展示也会清晰很多。再有就是每个写缓存的入口都必须显式声明TTL哪怕是暂时不确定过期时间的场景也先给一个比较长的默认值。没有任何过期时间的缓存key一旦积累多了内存会不知不觉被打满这是我在线上见过最多的隐形事故没有之一。Redis不难学难的是在一个又一个真实场景里把它的特性用对、用稳。希望这15个场景能帮你把它的能力边界看透下次碰到类似需求直接抄起来就用。