公司动态
Redis 实战:除了做缓存,还能干什么
开场某天半夜你负责的系统被高并发流量打垮了。数据库连接池打满接口超时用户开始投诉。DBA 看了一眼监控说“加个 Redis 缓存吧。” 你翻了翻文档配了SET和GET果然有效RT 从 800ms 掉到了 5ms。你松了一口气以为这事就结了。然后就停了。这个故事我见过太多次。Redis 在国内绝大多数团队里的定位就是数据库前面的那层缓存用完SET GET就收工。但实际上Redis 的能力远不止于此。分布式锁、排行榜、消息队列、接口限流、发布订阅、分布式 Session——这些都是 Redis 能干的活而且干得漂亮。你只是还没碰到这些场景或者碰过但不知道该用 Redis。这篇文章的目标就是让你知道 Redis 还能做什么以及每件事背后最常见的坑。如果你已经用过 Redis 缓存这篇文章会让你看到一片新大陆。一、Redis 数据结构不是只有 String很多人学 Redis第一条命令就是SET key value和GET key于是形成了一个刻板印象Redis 就是个键值存储。实际上 Redis 支持五种基础数据结构每一种都对应着真实的使用场景。String是最基础的类型存一个字符串但实际上它能存任何序列化的内容——JSON、XML、二进制文件内容都可以。Hash是字段值对适合存储对象。比如一个用户的信息用 Hash 存就是HMSET user:1001 name 张三 age 28 city 北京修改年龄直接HSET user:1001 age 29不需要像 String 那样先反序列化、修改、再序列化回去。List是有序的字符串列表天然适合做队列。新元素从左边LPUSH从右边BRPOP消费——这其实是 Redis 最简单的消息队列模式。另一个常用场景是最新消息列表比如微博 TimelineLPUSH新微博 ID同时LTRIM裁剪到固定长度内存可控。Set是无序不重复集合适合去重和关系判断。SADD tag:redis programming把标签加入集合SISMEMBER tag:redis programming检查某个标签是否存在。集合运算SUNION、SINTER在做共同好友、标签交集时特别管用。Sorted Set是带分数的有序集合这是被严重低估的数据结构。分数决定排序可以是积分、热度值、时间戳查询 top N 用ZREVRANGE按分数范围查询用ZRANGEBYSCORE。后面讲排行榜会专门展开。实际工作中Hash 和 String 是绝对主力。Sorted Set 用好了是神器但很多团队根本没用过它。List 和 Set 的使用频率次之但也都有明确的用武之地。踩坑提示把一个大 JSON 序列化后塞进 String 里这个做法不罕见。但问题是如果你只改了 JSON 里一个字段对不起你得把整个 JSON 读出来、反序列化、修改某个字段、再序列化、写回去。这两步全量 IO 在并发场景下是性能杀手。改成 Hash每个字段独立存取才是正确姿势。# String 做法每次都要全量读写SET article:1001{title:Redis实战,views:1000,likes:50}# 想加一个赞得先 GET 出来解析 JSON加 1再 SET 回去# Hash 做法字段独立操作HSET article:1001 titleRedis实战views1000likes50HINCRBY article:1001 likes1# 一条命令搞定二、缓存最常见的场景但也是最容易踩坑的地方缓存是 Redis 入门第一课这里不展开讲怎么 SET GET只聊几个真正容易出问题的地方。带过期时间的 SETEX。缓存不是越多越好数据是有时效性的。用SETEX cache:product:1001 3600 {id:1001,stock:50}设置 3600 秒过期的缓存到期自动删除你不需要额外的定时任务来清理。SETNX 与分布式锁的起点。SETNX是SET if Not eXists的缩写原子操作只有 key 不存在时才设置返回值 1存在则返回 0。这个命令是分布式锁和单次性任务保证比如防止重复下单、防止重复通知的基础。缓存穿透有人疯狂查询一个数据库里根本不存在的商品 ID因为不存在所以 Redis 里也没有每次都穿透到数据库。这种请求量大了数据库照样扛不住。解法有两个一是空值缓存查询一个不存在的 key 时也写入SET cache:product:-1 NULL EX 60二是布隆过滤器把所有存在的 ID 预先加载进去请求过来先问布隆过滤器存在才查 Redis 再查 DB。两种方案各有取舍前者简单但无法过滤非法 ID 的组合攻击后者内存占用小但有误判率宁可错杀不可放过。缓存击穿热点 key 比如商品详情因为访问量太大被放在了缓存里但它过期了。过期的瞬间1000 个并发请求同时发现缓存没了同时冲向数据库数据库瞬间被打回原形。解法SETNX cache:lock:product:1001 1 EX 10只有抢到锁的线程去查数据库并回填缓存其他线程等一小会儿再去读缓存。Redis 官方推荐的做法正是这个思路。缓存雪崩一批 key 设置了相同的过期时间结果这批 key 同时过期同时有大量请求打过来雪崩就发生了。解法说出来很简单过期时间加一个随机偏移量。EXPIRE cache:product:${id} $((3600 RANDOM % 300))这样每个 key 的过期时间错开几秒峰值就被削平了。最要命的踩坑以为加了缓存就不用管数据库了。Redis 是内存数据库内存满了会丢数据机器重启数据就没了。你对 Redis 的依赖程度决定了当 Redis 故障时你的服务能不能降级。如果 Redis 挂了你的服务直接全挂那这套架构是脆弱的需要准备熔断、降级、或者本地缓存兜底的方案。三、分布式锁SETNX 的正确打开方式先说清楚为什么要用分布式锁。假设你有两台应用服务器都收到了用户提现的操作请求——同一笔余额两个人同时提现如果不加控制两台机器各自查到的余额都是 1000各自减 1000都认为操作合法最终余额可能是 0 而不是 800。单机的 synchronized 只能锁住一台机器内部的线程跨机器就不管用了。分布式锁的核心诉求是同一时刻只有一个客户端能持有锁其他客户端看到锁被占用就等待。错误写法SET lock:order:1001uuid-xxx# 执行业务逻辑DEL lock:order:1001问题在哪里如果你的服务在 SET 之后、DEL 之前崩溃了锁永远不会被释放其他进程只能等到 key 自动过期如果你没设过期时间的话那就是永远等。如果设了过期时间进程 A 跑了 15 秒但锁只设了 10 秒过期锁自动释放了进程 B 抢到了锁开始干活进程 A 恢复后继续 DEL——把进程 B 的锁删了。两个进程同时以为自己持有锁数据就乱了。正确写法SET lock:order:1001uuid-xxxNX EX10NX保证原子性——只有 key 不存在时才设置成功EX 10设置 10 秒过期。就算进程崩溃了10 秒后锁也会自动释放不会死锁。释放锁也要原子进程 A 持有锁但任务跑得太慢10 秒锁自动释放了进程 B 抢到了锁开始干活这时进程 A 跑完了去 DEL——把进程 B 的锁删了。正确做法是用 Lua 脚本先检查 value 是否是自己的ifredis.call(GET,KEYS[1])ARGV[1]thenreturnredis.call(DEL,KEYS[1])elsereturn0end用进程唯一的 UUID 作为 value检查和删除合成一条原子命令避免误删别人的锁。现实中的做法自己写这套逻辑容易出 bug生产环境推荐直接用 Redisson。它把上述所有细节封装好了RLock lock redissonClient.getLock(lock:order:1001); lock.lock();用起来跟 Java 的 ReentrantLock 几乎一样只是可以跨 JVM 生效。踩坑锁的过期时间设太短是最常见的错误。任务需要 8 秒但锁只设了 5 秒业务还没跑完锁就丢了其他进程以为锁空出来了开始执行结果就是并发冲突。上线前务必在测试环境模拟最慢情况下的执行时间把过期时间设在这个时间之上再加一倍缓冲。四、排行榜Sorted Set 的经典用法游戏战力榜、积分商城兑换排行、社区文章热度排行——这些场景的共同特点是需要实时排序而且每次变化只是单个成员的分数调整不是全量重排。Sorted Set 完美匹配这个需求。核心命令# 玩家完成任务加 100 积分直接 ZADD 就更新了ZADD game:rank:20260807100player:1001# 查 top 10从高到低ZREVRANGE game:rank:2026080709WITHSCORES# 查某个玩家的排名从 0 开始加 1 才是日常说的第几名ZREVRANK game:rank:20260807player:1001用户每完成一个行为重新ZADD一次就行分数会自动更新。这个操作是 O(log N) 级别的比你每次从数据库查出来在代码里排序快几个数量级。游戏战力榜的典型实现是每天一个排行榜 ZSetkey 带上日期每天零点重置。查询时ZREVRANGE取 top N 加上自己的排名返回给前端渲染。复杂度可控数据量随玩家数量线性增长。踩坑一想查全服排名第 N 到第 M 名代码里ZRANGE全量取出再排序——当玩家数量超过十万时这个操作能把 Redis 堵死。正确做法是ZREVRANGE rank 9999 10018 WITHSCORESRedis 内部完成排序只返回你需要的那一段。踩坑二如果你要的排行不是按分数而是按某个综合指标比如活跃度 登录次数 × 3 付费金额 × 10Sorted Set 的单分数机制就撑不住了。这种情况要么定期离线计算后写入 ZSet要么考虑换其他方案——比如 ElasticSearch 的多字段排序。五、消息队列Redis 也能干但有条件严格来说Redis 原生的 List 结构就能实现一个简单的消息队列江湖人称Redis 队列很多人不知道这个。# 生产者往队列左边推消息LPUSH queue:orderorder:1001# 消费者从队列右边阻塞读取没有消息就等BRPOP queue:order0BRPOP的 0 表示无限等待有消息就返回没有就挂起比轮询RPOP省资源。这个模式在单机部署、轻量异步场景下完全够用。那为什么不都用 RabbitMQ 或 RocketMQ因为 Redis 不需要额外部署进程引入一个依赖就能用。对于日志收集、邮件发送这类不需要严格消息持久化的异步任务Redis 队列的成本是最低的。Redis Stream是 5.0 版本引入的数据结构专门面向消息队列场景比 List 队列更接近专业 MQ# 添加消息到流XADD mystream * field1 value1# 消费组多个消费者分工读取互不重复XGROUP CREATE mystream mygroup $# 消费消息从消费组的最后位置开始读XREADGROUP GROUP mygroup consumer1 STREAMS mystreamStream 支持消费组、消息 ID、消息持久化比 List 队列功能丰富得多。唯一的问题是它终究还是跑在 Redis 上的没有脱离 Redis 的内存本质。Redis 做 MQ 的边界在哪里它没有事务消息的支持不保证消息exactly-once 语义进程崩溃时如果还没 Ack 消息可能丢失。而且 Redis 的内存是有限的消息积压过多会触发内存淘汰策略数据同样会丢。用它做日志收集、延迟任务ZSET 时间戳分数 定时轮询没问题拿它当支付消息、订单流转的主力队列——不推荐。踩坑用 Redis Stream 做消息队列的时候消费组里的每个 consumer 都要定期发送心跳XACK确认消息已处理。如果你的消费者进程崩溃了没有 ACK 的消息会被其他 consumer 重新消费这本是好事但如果你没有做幂等处理同一条消息被处理两次就会出问题。六、限流滑动窗口算法的 Redis 实现接口防刷是每个后端工程师都会遇到的场景。限制同一个 IP 每秒最多请求 100 次或者限制每个用户每分钟调用某个接口不超过 60 次——这类需求用 Redis 实现既简单又精准。简单实现INCR EXPIRE# 每个请求过来先 1INCR rate:ip:192.168.1.100# 第一次执行 INCR 时会创建 key此时给它设置过期时间EXPIRE rate:ip:192.168.1.1001这个方案的问题在于第 100 毫秒请求一次key 加 1第 900 毫秒请求一次key 加 1到 1001 毫秒时 key 过期了重新计数——用户实际上在两秒内请求了两次但逻辑上只被限流了一次。这个误差在严格限流场景下是不可接受的。精确实现ZSet 滑动窗口把每次请求的时间戳作为 member分数也是时间戳。每次请求时用ZREMRANGEBYSCORE删除窗口外的旧记录查ZCARD数量如果小于限制就ZADD写入当前请求-- 窗口大小 1 秒限制 100 次localkeyKEYS[1]localnowtonumber(ARGV[1])localwindowtonumber(ARGV[2])locallimittonumber(ARGV[3])-- 删除 1 秒之前的所有请求记录redis.call(ZREMRANGEBYSCORE,key,0,now-window)-- 统计当前窗口内请求数localcountredis.call(ZCARD,key)ifcountlimitthenredis.call(ZADD,key,now,now)redis.call(EXPIRE,key,window)return1-- 允许通过elsereturn0-- 被限流end这个脚本在单次请求里完成检查和写入原子执行窗口滑动精准没有边界突刺问题。踩坑用INCR但忘了EXPIREkey 会永不过期地累积下去直到 Redis 内存被打满。用INCR加EXPIRE时还有一个坑如果两条命令之间服务重启了EXPIRE没执行key 也不会过期。解决方案是用SET key 1 EX 1 NX的组合形式一个命令同时搞定赋值和过期原子且安全。七、分布式 Session多机器共享用户状态水平扩展应用服务时每个请求可能被路由到不同的机器上。如果 Session 存在单机内存里用户第一次请求落在机器 ASession 写到了机器 A 的内存第二次请求被负载均衡打到机器 BB 上没有这个 Session用户就被登出了。把 Session 存到 Redis 里所有应用服务器共享同一份数据这个问题就消失了。用户登录成功后把 Session 数据写入 RedisSET session:abc123{userId:1001,username:张三,loginAt:1723000000}EX1800Session ID 通过 Cookie 返回给浏览器后续请求带上这个 Cookie应用服务器从 Redis 读出 Session 数据验证身份。这个流程在 Spring Security Spring Session Data Redis 框架下只需要几行配置不需要你手动写SET GET。踩坑Session 过期时间和 Redis key 的 TTL 要保持一致。Session 设了 30 分钟过期Redis key 设了 20 分钟 TTL用户在第 25 分钟还在操作Redis key 先过期了Session 丢了用户莫名其妙被踢回登录页。这个 bug 用户感知非常明显但排查起来又很容易被忽略——Redis 和 Session 框架各自管理过期时间要盯着对一下。八、发布订阅Redis 的消息广播Redis 内置了发布订阅模式客户端可以订阅频道发布者往频道发消息所有订阅者同时收到。# 订阅频道SUBSCRIBE config:updates# 另一个终端发布消息PUBLISH config:updates{key:maxConnections,value:100}实际场景里最常用的是配置变更通知。比如你的应用集群有 10 台机器某个管理员在后台改了系统配置需要让所有机器重新加载。用 Redis Pub/Sub 发布一条消息10 台机器同时收到通知各自执行本地配置重载比每台机器轮询数据库快得多。通配符订阅可以订阅一类频道PSUBSCRIBE config:*这样config:db、config:cache、config:logging的消息都能收到灵活性更高。踩坑Pub/Sub 是即发即弃fire-and-forget的消息发出去了订阅者有没有收到、收到之后怎么处理Redis 是不管的。如果订阅者进程在消息发出之后、接收之前崩溃了这条消息就永远丢了。对于可靠的消息传递需求这个缺陷是致命的——比如订单状态变更通知不能接受丢失。Redis 6.2 引入了PUBSUB SHARDNUMSUB和SPUBLISH/SUBSCRIBEShard pub/sub在集群环境下提供了更好的扩展性但仍然没有改变即发即弃的本质。如果你的业务对消息可靠性有要求换 Kafka 或者 RocketMQ。结尾Redis 能做的事比多数人以为的多得多。缓存、分布式锁、排行榜、消息队列、限流、Session 共享、发布订阅——这些场景在业务发展过程中几乎都会遇到而你只需要一个 Redis 客户端就能全部覆盖不需要为每个场景引入单独的中间件。但 Redis 的边界也很清晰。它是内存数据库不是持久化数据库数据在内存里丢了就是丢了。它是工具不是银弹你不能把关键业务数据全押在 Redis 上也不能用它替代所有消息队列和存储系统。知道 Redis 能做什么同时清醒地知道它做不了的才是真正用好 Redis 的开始。下次遇到分布式问题的时候先想想Redis 能帮我解决这个吗很大概率上答案是能。