Redis在项目中到底怎么用:从缓存到分布式锁的工程实践
Redis 在项目里到底该怎么用,这个问题我在不同团队里被问过不下二十次。每次有人新接手一个项目,看到配置文件里那一串spring.redis或者REDIS_URL,第一反应往往是“哦,缓存嘛”,然后就开始往里塞各种set和get。等到线上出现缓存和数据库不一致、热点 key 把某个节点打满、或者某个业务突然发现自己的数据被别的业务覆盖了,才回过头来重新审视这个“缓存”到底该怎么用。这篇内容就是把我这些年在一个又一个项目里踩过的坑、做过的取舍、以及最后沉淀下来的那套用法,完整地摊开讲一遍。
Redis 本质上是一个内存里的数据结构服务器,它能做的事情远不止“存个字符串然后取出来”。在真实项目里,它同时扮演着缓存、分布式锁、计数器、排行榜、会话存储、消息队列轻量替代、限流器等多重角色。问题在于,很多人只把它当缓存用,于是把锁、计数、队列这些需求用别的方式硬凑,结果系统里多了一堆组件,运维成本和一致性风险反而更高。这篇内容适合两类人看:一类是刚接触 Redis、想知道它在项目里到底能承担哪些职责的开发者;另一类是已经在用 Redis、但总感觉用得“不太对劲”、想系统梳理一遍用法和边界的老手。我会从整体设计思路讲到具体落地步骤,再到线上排查的真实案例,尽量让每个环节都能直接拿去对照自己的项目。
1. 项目里引入 Redis 的整体设计与取舍
1.1 先想清楚:你要的是缓存,还是数据结构服务
很多人一上来就问“Redis 怎么配置”,但真正该先回答的问题是:这个项目引入 Redis,是为了解决读性能,还是为了用它的数据结构能力。这两个目标对应的设计完全不一样。如果只是为了缓解数据库读压力,那核心关注点是缓存命中率、过期策略、以及缓存与数据库的一致性;如果是为了用它的原子操作、有序集合、发布订阅这些能力,那核心关注点就变成了数据模型设计、命令复杂度、以及持久化与可靠性。
我见过一个典型场景:某项目需要实现一个“每日签到排行榜”,最初的做法是每次签到往数据库写一条记录,排行榜靠定时任务聚合。数据量一上来,聚合任务跑不动,页面加载要好几秒。后来改成用 Redis 的有序集合(ZSET),签到就是ZINCRBY,排行榜就是ZREVRANGE,整个链路从秒级降到毫秒级。这个例子里 Redis 承担的不是缓存角色,而是实打实的数据结构服务。所以第一步永远是明确职责边界,而不是先写配置。
这里有个判断标准可以借用:如果一个需求涉及“高频读写、对一致性要求可以容忍短暂延迟、数据可以重建”,那它适合放进 Redis 做缓存;如果一个需求涉及“需要原子性、需要按分数或时间排序、需要跨进程共享状态”,那它适合用 Redis 的数据结构能力。两者可以共存于同一个 Redis 实例,但最好在 key 的命名空间上做隔离,避免互相干扰。
1.2 单实例、主从还是集群:选型背后的真实考量
选型这件事,最怕的是“为了架构而架构”。我见过不少项目,日活还没过万,上来就搭一套集群,结果运维复杂度陡增,跨槽(slot)操作各种报错,最后性能也没见提升多少。反过来也有项目,单实例扛着几十万 QPS 的读,因为数据量小、全是读多写少,跑得稳稳当当。
我的经验是分三档来看。第一档是单实例加持久化,适合数据量在几个 GB 以内、QPS 在几万级别、对可用性要求不是极端苛刻的场景,绝大多数中小项目都落在这一档。第二档是主从加哨兵,解决的是单点故障问题,读可以分摊到从节点,写仍然集中在主节点,适合读多写少、需要自动故障转移的场景。第三档才是集群,解决的是单机内存和写并发的上限问题,代价是跨槽操作受限、客户端要支持集群模式、运维要处理槽迁移。
这里有个容易被忽略的点:集群模式下,涉及多个 key 的操作(比如MGET、事务、Lua 脚本)要求这些 key 落在同一个槽里,通常靠 hash tag(用{}包裹 key 的一部分)来保证。如果项目里大量使用多 key 操作,上集群之前一定要先评估改造量,否则上线后会发现一堆命令直接报CROSSSLOT错误。我个人的建议是,除非单实例的内存或写并发确实到了瓶颈,否则优先用主从加哨兵,把集群留到真正需要的时候再上。
1.3 key 的命名规范:小细节决定后期可维护性
key 的命名看起来是小事,但它直接决定了后期排查问题时的效率。我见过最混乱的情况是,同一个 Redis 实例里,有的 key 是user:1,有的是user_1,还有的是USER:1,排查一个线上问题时得把所有可能的拼写都试一遍。规范的命名应该包含三部分:业务前缀、对象类型、对象标识,用冒号分隔,比如order:detail:10086、user:session:abc123。
冒号分隔不只是好看,它在可视化工具里会自动形成层级结构,排查时能按业务分组浏览。另外,key 里尽量不要放会变化的高基数字段做前缀,否则在集群模式下容易造成数据倾斜。还有一个实践是给不同业务分配不同的前缀,配合SCAN命令按前缀遍历,避免用KEYS *这种会阻塞整个实例的危险操作。这些规范最好在项目初期就写进团队约定,等到 key 数量上百万再回头治理,成本会高得多。
2. 缓存场景的核心细节与实操要点
2.1 缓存穿透、击穿、雪崩:三种问题三种解法
这三个词经常被混着说,但它们对应的是完全不同的场景和解法。缓存穿透是指查询一个数据库里根本不存在的 key,缓存永远不命中,请求全部打到数据库;缓存击穿是指某个热点 key 刚好过期,瞬间大量请求同时打到数据库;缓存雪崩是指大量 key 在同一时间集中过期,或者 Redis 实例整体不可用,导致请求全部涌向数据库。
针对穿透,最常用的做法是缓存空值,也就是数据库查不到也往 Redis 里写一个带短过期时间的空标记,比如order:detail:999存一个空字符串,过期时间设 60 秒。这样短时间内重复查询同一个不存在的 key 就不会打到数据库。更严谨的做法是配合布隆过滤器,在缓存之前先挡一层,但布隆过滤器有误判率,且删除麻烦,适合 key 集合相对稳定的场景。
针对击穿,核心思路是让同一个 key 的并发回源只有一个请求真正打到数据库。常见做法是用分布式锁或者本地锁,拿到锁的线程去查数据库并回填缓存,其他线程短暂等待后重试读缓存。这里要注意锁的过期时间要大于数据库查询的耗时,否则锁提前释放会导致重复回源。
针对雪崩,最直接的做法是给过期时间加随机抖动,比如基础过期时间 30 分钟,再加 0 到 5 分钟的随机值,避免大批 key 同时失效。另外,对于特别热的数据,可以设置逻辑过期(value 里带一个过期时间戳,物理上不过期),由后台异步刷新,这样热点 key 永远不会出现“过期瞬间”的空窗。
| 问题类型 | 触发条件 | 核心解法 | 注意事项 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的 key | 缓存空值、布隆过滤器 | 空值过期时间不宜过长 |
| 缓存击穿 | 热点 key 过期瞬间 | 分布式锁回源、逻辑过期 | 锁超时需大于查询耗时 |
| 缓存雪崩 | 大批 key 同时过期 | 过期时间加随机抖动 | 抖动范围按业务调整 |
2.2 缓存与数据库的一致性:能接受多短的延迟
一致性问题是没有银弹的,关键在于业务能接受多长的延迟。常见的策略有三种:先更新数据库再删除缓存(Cache Aside)、先删除缓存再更新数据库、以及基于 binlog 的异步补偿。第一种最常用,因为它的不一致窗口很短,只有在“删除缓存成功但更新数据库失败”或者“并发读写交错”时才会出问题。
我实际项目里用得最多的是“更新数据库后删除缓存”,并且给删除操作加重试。原因是直接更新缓存容易在并发场景下写出脏数据,而删除缓存让下一次读自然回源,逻辑更简单。这里有个细节:删除缓存失败怎么办?我的做法是把删除失败的 key 扔进一个消息队列或者本地重试队列,延迟重试几次,实在不行就靠缓存的过期时间兜底。所以缓存一定要设过期时间,它是最后一道保险。
还有一种情况是“先删缓存再更新数据库”,在读写并发很高时容易出现:线程 A 删了缓存,线程 B 读缓存未命中,回源读到旧数据并写回缓存,然后线程 A 才更新数据库,此时缓存里就是旧数据。所以除非有特殊的延迟双删机制,否则我更倾向于 Cache Aside 的顺序。延迟双删就是在更新数据库后,延迟一段时间再删一次缓存,把并发窗口内可能写回的旧数据清掉,延迟时间一般取读操作耗时加一点余量。
2.3 序列化方式的选择:别让一个配置拖慢整个链路
序列化这件事,很多人直接用默认的 JDK 序列化,结果发现存进去的东西在可视化工具里是一堆乱码,排查问题时完全看不懂,而且 JDK 序列化体积大、性能一般。我的建议是优先用 JSON 序列化,可读性好,跨语言兼容,排查时一眼能看懂内容。代价是体积比二进制协议略大,但对绝大多数业务来说完全可以接受。
如果对体积和性能有更高要求,可以考虑 Protobuf 或者 MessagePack,但要注意版本兼容和调试成本。还有一个坑是:不同服务用不同的序列化方式写同一个 key,读的时候就会解析失败。所以序列化方式最好在团队内统一,或者至少在 key 的命名上体现出来,比如user:json:1和user:pb:1分开。另外,对象里如果有字段增删,JSON 的反序列化相对宽容,而某些二进制协议对字段顺序和类型更敏感,升级时要格外小心。
3. 分布式锁与原子操作的落地实现
3.1 用 SET NX PX 实现一把靠谱的锁
分布式锁是 Redis 在项目里除了缓存之外最常见的用途。核心命令就是SET key value NX PX milliseconds,NX 保证只有 key 不存在时才设置成功,PX 设置过期时间防止死锁。value 要放一个唯一标识,比如 UUID,释放锁的时候要先比对 value 再删除,而且比对和删除必须是原子操作,通常用 Lua 脚本实现。
为什么 value 要唯一?因为如果线程 A 的锁过期了,线程 B 拿到了锁,此时线程 A 执行完业务去释放锁,如果不比对 value,就会把线程 B 的锁误删。这个场景在业务执行时间超过锁过期时间时非常容易发生。所以锁的过期时间要留足余量,同时业务侧最好有“锁续期”机制,也就是常说的看门狗,在业务还没执行完时定期延长锁的过期时间。
释放锁的 Lua 脚本大概是这样:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这段脚本保证“比对 value”和“删除 key”在 Redis 内部原子执行,不会被其他命令插队。实际使用时,KEYS[1]是锁的 key,ARGV[1]是加锁时写入的唯一标识。注意不要在脚本里做复杂逻辑,Lua 脚本执行期间会阻塞其他命令,脚本越短越好。
3.2 锁的粒度与超时:两个最容易出事的参数
锁的粒度决定了并发度。锁太粗,比如整个订单模块用一把锁,那所有订单操作都串行,性能直接崩掉;锁太细,比如每个字段一把锁,那组合操作又无法保证原子性。我的经验是按业务聚合根来加锁,比如按订单 ID 加锁,lock:order:10086,这样同一订单的操作串行,不同订单并行,粒度比较合理。
超时时间是最容易出事的参数。设太短,业务没执行完锁就释放了,并发问题照样出现;设太长,一旦持锁进程崩溃,其他请求要等很久才能拿到锁。我的做法是先用压测或者日志统计出业务执行时间的 P99,然后把锁超时设成 P99 的两到三倍,同时开启续期机制。续期不是万能的,如果进程真的挂了,续期也会停,所以超时时间仍然是最后的兜底。
还有一个实践是给锁加一个“等待超时”,也就是获取锁时最多等多久,超过就返回失败或者走降级逻辑,避免请求无限期阻塞把线程池耗尽。这个等待超时通常设得比锁超时短很多,比如锁超时 30 秒,等待超时 3 秒,拿不到就快速失败。
3.3 原子计数器与限流:用对命令比写对代码更重要
计数器场景用INCR和INCRBY就够了,它们是原子操作,不需要额外加锁。比如统计某个接口的调用次数,每次请求INCR api:count:20240101,再配合EXPIRE设置按天过期。这里有个细节:INCR和EXPIRE是两条命令,如果中间进程挂了,可能留下一个没有过期时间的 key。解决办法是用 Lua 脚本把两条命令包在一起,或者用SET带EX参数初始化后再INCR。
限流场景常用的是固定窗口计数和滑动窗口。固定窗口就是按时间分桶计数,实现简单但有临界问题,比如限制每分钟 100 次,可能在 59 秒和 61 秒各打 100 次,实际两秒内打了 200 次。滑动窗口可以用有序集合实现,每次请求把时间戳作为 score 存进去,然后统计窗口内的数量,超过阈值就拒绝。有序集合方案更精确,但内存占用随请求量增长,适合对精度要求高的场景。
令牌桶是另一种常见思路,用 Redis 存当前令牌数和上次填充时间,每次请求计算应该补充多少令牌,然后扣减。这个逻辑用 Lua 脚本实现可以保证原子性。实际选型时,如果只是粗粒度限流,固定窗口足够;如果对突发流量敏感,用令牌桶或者滑动窗口更合适。
4. 线上常见问题与排查技巧实录
4.1 大 key 和热 key:两个拖垮实例的隐形杀手
大 key 指的是单个 value 体积过大,比如一个 String 存了几 MB 的 JSON,或者一个 Hash 有几十万个字段。它的危害在于:读写时网络传输耗时增加、删除时可能阻塞、集群模式下造成数据倾斜。热 key 指的是某个 key 的访问量远超其他 key,把单个节点的 CPU 或网络打满。
排查大 key 可以用redis-cli --bigkeys,它会扫描并列出各类型最大的 key。但要注意这个命令在数据量大时也会有一定开销,最好在从节点或者低峰期执行。更精细的做法是用MEMORY USAGE key查看单个 key 的内存占用。治理大 key 的思路是拆分,比如把一个大 Hash 按字段前缀拆成多个小 Hash,或者把大 JSON 拆成多个小 key 分别存储。
热 key 的排查相对麻烦,因为它是访问频率问题而不是体积问题。可以在客户端做本地计数,统计一段时间内各 key 的访问次数,上报后聚合分析。治理热 key 的思路是加本地缓存,让热点数据在应用进程内也存一份,减少对 Redis 的访问;或者把热 key 复制成多份,比如hot:key:1到hot:key:10,请求时随机选一个,把压力分散到不同节点。
4.2 内存告警与淘汰策略:别等 OOM 了才想起来配置
Redis 是内存数据库,内存满了之后的行为由淘汰策略决定。常见的策略有noeviction(不淘汰,写入直接报错)、allkeys-lru(所有 key 里按 LRU 淘汰)、volatile-lru(只淘汰设置了过期时间的 key)。如果项目把 Redis 当纯缓存用,allkeys-lru比较合适;如果里面还存了不能丢的数据(比如锁、计数器),那就要谨慎,最好把缓存和持久数据分实例部署,避免缓存把持久数据挤掉。
内存告警的排查思路是先看INFO memory里的used_memory和used_memory_rss,再看maxmemory配置是否合理。如果发现内存持续增长但业务量没变,大概率是有 key 没有设置过期时间,或者有大 key 在缓慢膨胀。可以用SCAN配合TTL抽样检查没有过期时间的 key,重点排查那些本该是缓存却没设过期的数据。
还有一个容易忽略的点是内存碎片率,mem_fragmentation_ratio如果长期大于 1.5,说明碎片较多,可以考虑开启主动碎片整理,或者择机重启实例。但重启是有风险的,尤其是单实例场景,所以更稳妥的做法是在低峰期做,并且提前做好数据备份。
4.3 慢查询与连接数:两个最容易被忽视的指标
慢查询的排查靠SLOWLOG GET,它会列出执行时间超过阈值的命令。阈值由slowlog-log-slower-than配置,单位是微秒。我一般会把它设成 10000,也就是 10 毫秒,超过这个时间的命令都值得看一眼。常见的慢命令包括KEYS *、大范围的ZRANGE、对大集合做SMEMBERS、以及复杂的 Lua 脚本。治理思路是改用SCAN系列命令做增量遍历,或者把大范围操作拆成小批次。
连接数问题在客户端使用连接池时比较常见。如果连接池配置过大,可能把 Redis 的maxclients打满,新连接被拒绝;配置过小,又会导致请求排队。排查时看INFO clients里的connected_clients,结合应用的连接池配置和并发量来评估。我的经验是连接池的最大连接数不要超过 Redismaxclients的七成,给运维和其他应用留出余量。
| 排查项 | 常用命令 | 典型阈值 | 治理方向 |
|---|---|---|---|
| 大 key | redis-cli --bigkeys、MEMORY USAGE | 单 value 超过 10KB 需关注 | 拆分 key、压缩 value |
| 热 key | 客户端计数上报 | 单 key QPS 占比过高 | 本地缓存、key 复制分散 |
| 内存 | INFO memory | 使用率超 80% 告警 | 设过期、清无用 key、扩容 |
| 慢查询 | SLOWLOG GET | 超过 10ms 记录 | 改 SCAN、拆批次 |
| 连接数 | INFO clients | 超过 maxclients 七成 | 调连接池、查连接泄漏 |
4.4 持久化配置:RDB 和 AOF 到底怎么选
持久化这块,很多人直接默认配置就上了,结果某次实例重启后发现数据丢了一大截。RDB 是快照,按时间间隔把内存数据落盘,恢复快但可能丢最后一次快照之后的数据;AOF 是追加日志,记录每条写命令,丢数据少但文件大、恢复慢。实际项目里我一般两个都开,RDB 用于定期备份和快速恢复,AOF 用于减少数据丢失。
AOF 有个appendfsync配置,always每条命令都刷盘最安全但性能最差,everysec每秒刷一次是折中方案,no交给操作系统决定性能最好但最不安全。绝大多数场景用everysec就够了,最多丢一秒的数据。另外 AOF 文件会越来越大,需要配置重写(rewrite)策略,在文件增长到一定比例时触发压缩。这些配置最好在项目上线前就定好,而不是等出了问题再补。
5. 把 Redis 用稳的几个工程习惯
5.1 给每个 key 都设过期时间,除非有明确理由不设
这条听起来很基础,但它是避免内存无限增长最有效的手段。我在项目里推行的规则是:任何写入 Redis 的 key,代码里必须显式设置过期时间,除非在评审时能说清楚为什么这个 key 需要永久保留。缓存类数据自不必说,会话、验证码、临时标记这些都应该有过期时间。永久 key 只留给真正需要长期存在的配置类或者计数器类数据,而且这类数据要有独立的容量评估。
配合这条规则,可以在代码层面做一层封装,把set操作统一走一个工具方法,方法签名强制要求传入过期时间,不传就编译不过或者抛异常。这种“用工具约束习惯”的做法,比靠人自觉可靠得多。我待过的一个团队就是这么做的,上线半年后内存增长曲线明显平缓了很多。
5.2 监控要覆盖命中率、延迟和内存三个维度
监控不是配个内存告警就完事了。命中率反映缓存是否有效,如果命中率长期偏低,说明缓存策略有问题,可能是 key 设计不合理或者过期时间太短;延迟反映服务质量,P99 延迟突然升高往往意味着有大 key 或者慢命令;内存反映容量趋势,需要结合业务增长来预判扩容时机。这三个维度缺一不可。
具体指标上,命中率看keyspace_hits和keyspace_misses的比值,延迟可以在客户端埋点统计,内存看used_memory和mem_fragmentation_ratio。告警阈值不要设得太敏感,否则天天误报,团队会逐渐忽略告警。我的经验是内存使用率 80% 告警、命中率低于预期值持续一段时间告警、P99 延迟超过基线两倍告警,这样既能及时发现问题,又不至于噪音太多。
5.3 变更前先在预发环境压一遍
Redis 相关的变更,比如改过期时间、改序列化方式、加新的数据结构用法,看起来都很小,但影响面可能很大。我养成的习惯是任何涉及 Redis 的变更,先在预发环境用接近生产的数据量和并发压一遍,重点看内存增长、延迟变化和命中率波动。有一次改了一个 key 的过期时间,从 1 小时改成 24 小时,预发压测时发现内存涨了三倍,及时拦下来重新评估了容量,避免了生产环境的内存告警。
压测不需要很复杂,用现成的压测工具模拟核心读写链路就行,关键是数据量要接近真实。如果预发环境数据量太小,可以人为构造一批接近生产规模的测试数据。这个习惯看起来费事,但它拦下的问题往往比它花的时间值钱得多。
5.4 客户端配置里的几个关键参数
客户端连接池的配置直接影响稳定性和性能。最大连接数前面说过,不要超过服务端maxclients的七成。最小空闲连接数建议设成峰值并发的十分之一左右,避免频繁创建销毁连接。获取连接的超时时间要设短一些,比如 500 毫秒到 1 秒,拿不到连接快速失败,而不是无限等待。命令执行超时也要设,避免某个慢命令把线程一直占着。
还有一个参数是重试次数,网络抖动时适度重试能提高成功率,但重试次数太多会在故障时放大流量。我的做法是重试一到两次,并且重试之间加短暂退避。另外,客户端要能感知主从切换,哨兵模式下配置好哨兵地址列表,集群模式下配置好节点列表,避免主节点切换后客户端还连着旧地址。
6. 一个完整的落地示例:从需求到上线
6.1 需求拆解与数据模型设计
假设有个商品详情页,需要展示商品基础信息、库存、以及近一小时的浏览量。基础信息和库存变化不频繁但读取量极大,浏览量是高频递增的计数。这个需求拆下来,基础信息和库存适合做缓存,浏览量适合用计数器。数据模型上,商品基础信息用 String 存 JSON,key 是product:detail:{id},过期时间 30 分钟加随机抖动;库存单独用 String 存数字,key 是product:stock:{id},因为库存需要原子扣减;浏览量用 String 计数器,key 是product:views:{id}:{hour},按小时分桶,过期时间设 2 小时。
为什么库存不跟基础信息放一起?因为库存需要频繁原子扣减,放在 JSON 里就得先读出来再写回去,不是原子操作,并发下会超卖。单独用 String 配合DECR或者 Lua 脚本做“判断加扣减”,才能保证原子性。浏览量按小时分桶是为了避免单个 key 无限增长,同时方便统计近一小时的数据。
6.2 核心读写链路的代码结构
读商品详情的链路是:先读缓存,命中就返回;未命中就查数据库,查到后回填缓存,查不到就写一个短过期的空值。库存扣减的链路是:用 Lua 脚本判断库存是否充足,充足则扣减并返回成功,不足则返回失败。浏览量递增就是简单的INCR加EXPIRE,用 Lua 包一下保证原子性。
-- 库存扣减脚本 local stock = tonumber(redis.call("get", KEYS[1])) if stock == nil then return -1 end if stock < tonumber(ARGV[1]) then return 0 end redis.call("decrby", KEYS[1], ARGV[1]) return 1返回值约定:-1 表示库存 key 不存在(需要回源初始化),0 表示库存不足,1 表示扣减成功。这样调用方可以根据返回值做不同处理,逻辑清晰。注意脚本里不要做数据库查询之类的操作,Lua 脚本只做 Redis 内部的原子逻辑。
6.3 上线前的检查清单
上线前我会过一遍这几项:所有 key 是否都有过期时间(永久 key 是否有评审记录)、缓存空值的过期时间是否合理、库存扣减脚本是否覆盖了 key 不存在的情况、连接池参数是否按并发量调过、监控告警是否配好、预发压测是否通过。这几项看起来琐碎,但每一项都对应过线上事故。尤其是库存脚本里 key 不存在的情况,如果不处理,第一次扣减就会返回错误,用户体验直接崩掉。
另外,上线后要盯一段时间的命中率和延迟曲线,确认没有异常抖动。如果发现某个 key 的命中率异常低,先查是不是 key 拼写不一致或者过期时间设得太短。这些检查做完,一个 Redis 相关的功能才算真正落地。
7. 一些不那么常见但很有用的用法
7.1 用 HyperLogLog 做去重统计
统计独立访客数(UV)这种需求,如果用 Set 存每个用户 ID,内存占用随用户量线性增长,百万级用户就要几十 MB。HyperLogLog 用极小的固定内存(约 12KB)就能估算基数,误差在 0.81% 左右。对于不需要精确值的统计场景,比如页面 UV、搜索词去重计数,它非常合适。命令就是PFADD和PFCOUNT,用法简单。
要注意的是 HyperLogLog 只能估算不能精确计数,也不能取出具体元素,所以它适合“只要一个大概数字”的场景。如果业务要求精确,还是得用 Set 或者位图。另外多个 HyperLogLog 可以合并,PFMERGE能把按天统计的结果合并成按周、按月,这个特性做周期统计很方便。
7.2 用 Bitmap 做签到和状态标记
签到场景用 Bitmap 非常省内存,一个用户一年的签到状态只需要 365 个 bit,不到 46 字节。实现方式是把用户 ID 作为 key 的一部分,日期作为 offset,签到就是SETBIT key offset 1,查询某天是否签到就是GETBIT,统计一段时间内的签到天数可以用BITCOUNT。相比用 Set 存日期字符串,Bitmap 的内存优势非常明显。
Bitmap 的局限是 offset 必须是整数,所以它适合用连续整数标识的对象,比如用户 ID、日期序号。如果标识不是连续的,用 Bitmap 会浪费大量空间。另外 Bitmap 的统计是按位统计,做“统计连续签到天数”这种需求需要额外逻辑,不能直接靠BITCOUNT解决。
7.3 用 Stream 做轻量消息队列
Redis 5.0 引入的 Stream 类型,提供了消费组、消息确认、消息回溯这些消息队列的基本能力。对于不想引入独立消息中间件、但又需要一定可靠性的场景,Stream 是个不错的选择。XADD生产消息,XREADGROUP消费消息,XACK确认消费,配合XPENDING查看未确认消息,基本能覆盖中小规模的消息场景。
但要说清楚它的边界:Stream 的消息存在内存里,堆积过多会占满内存;它没有独立消息中间件那样的持久化和多副本保障;消费组的负载均衡能力也相对简单。所以它适合消息量不大、对可靠性要求不是极端高的内部场景,比如异步通知、任务分发。如果消息量很大或者要求严格不丢,还是应该用专门的消息中间件。
8. 我个人在实际项目中的几点体会
用 Redis 这些年,最大的体会是:它的能力边界比很多人以为的宽,但它的可靠性边界比很多人以为的窄。宽在数据结构丰富,能覆盖缓存、锁、计数、队列、统计等多种需求;窄在它是内存系统,持久化和多副本能力有限,不能当主数据库用。把这两点想清楚,很多设计上的纠结就自然解开了。
第二个体会是,Redis 用得好不好,八成取决于 key 的设计和过期策略,而不是用了多高级的命令。我见过太多项目,命令用得花里胡哨,但 key 命名混乱、过期时间随意,最后线上问题不断。反过来,key 设计清晰、过期策略严谨的项目,哪怕只用最基础的GET和SET,也跑得很稳。
最后分享一个小技巧:在项目里建一个 Redis 使用规范文档,把 key 命名、过期时间、序列化方式、锁的实现、监控指标这些约定写进去,新同学入职先读这个文档。这件事花不了多少时间,但能避免大量重复踩坑。规范不是一成不变的,随着项目演进定期回顾和更新,让它真正成为团队共识,而不是躺在文档库里吃灰。