Redis 生产环境实战:数据结构选型、持久化与缓存三大问题

Redis 生产环境实战:数据结构选型、持久化与缓存三大问题
神经蛙Redis 大多数人都”会用”,但线上事故往往出在选型和边界情况上:一个 KEYS * 拖垮整个实例,一次缓存雪崩打穿数据库。本文不讲安装,只讲生产环境真正会遇到的问题。
一、数据结构怎么选
选错数据结构的代价,往往是内存翻几倍、或者用一堆笨拙的组合去模拟本该原生支持的能力。
1.1 五种基础类型对比
| 类型 | 底层实现 | 典型场景 | 单键建议上限 |
|---|---|---|---|
| String | SDS 动态字符串 | 缓存、计数器、分布式锁、Session | 值不超过 10 KB |
| Hash | 压缩列表 / 哈希表 | 对象存储(购物车、用户资料) | field 不超过 1000 |
| List | 快速链表 | 消息队列、最新列表、粉丝列表 | 元素不超过 1 万 |
| Set | 哈希表 / 整数集合 | 去重、标签、共同好友、抽奖 | 元素不超过 1 万 |
| ZSet | 跳表 + 哈希表 | 排行榜、延迟队列、优先级队列 | 元素不超过 1 万 |
1.2 容易被忽略的选型细节
String 存对象 vs Hash 存对象
很多人图省事直接把对象序列化成 JSON 塞进 String:
1 | SET user:1001 '{"name":"张三","age":28,"city":"杭州"}' |
问题是改一个字段要把整个对象读出来、反序列化、改完再写回去。换成 Hash 就能按字段操作:
1 | HSET user:1001 name 张三 age 28 city 杭州 |
ZSet 不只是排行榜
跳表结构让 ZSet 支持按 score 范围查询,天然适合做延迟队列:
1 | # 下单后 30 分钟未支付自动关闭,用时间戳做 score |
1.3 高级类型:能省下大量内存
| 类型 | 用途 | 内存效果 |
|---|---|---|
| Bitmap | 每日签到、活跃用户标记 | 1 亿用户每日签到仅约 12.5 MB |
| HyperLogLog | UV 统计 | 每个 key 固定约 12 KB,误差 0.81% |
| Geo | 附近的人、门店距离 | 基于 ZSet 实现 |
| Stream | 消息队列(Redis 5.0+) | 支持消费组、ACK、持久化 |
HyperLogLog 的内存优势非常夸张:统计 1 亿个独立用户,用 Set 存所有 userId 大约要几个 GB,用 HyperLogLog 只要 12 KB —— 代价是 0.81% 的误差,对 UV 这类统计完全够用。
1 | PFADD uv:20260831 user1 user2 user3 |
二、缓存三大问题
这是面试必考,也是线上真会出事的三个坑。
2.1 缓存穿透:查一个根本不存在的数据
现象:请求的数据数据库里也没有,缓存自然不会命中,每次请求都直接打到数据库。如果被恶意刷不存在的 id,数据库会被打挂。
解法一:缓存空值
1 | public User getUser(Long id) { |
解法二:布隆过滤器
在数据写入时就同步到布隆过滤器,查询前先过一遍。布隆过滤器说”不存在”,那一定不存在,直接返回。
1 | // 初始化时把所有有效 id 载入布隆过滤器 |
| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空值 | 实现简单,无额外组件 | 占内存,短期数据不一致 |
| 布隆过滤器 | 内存极省,判断快 | 有误判率,删除困难,需预热 |
2.2 缓存击穿:一个热点 key 刚好过期
现象:某个被疯狂访问的热点 key(比如首页头图、秒杀商品)在失效的瞬间,成千上万的请求同时穿透到数据库。和穿透不同,击穿针对的是同一个 key。
解法一:互斥锁重建
1 | public Product getHotProduct(Long id) { |
解法二:逻辑过期(推荐)
不给 key 设置真实 TTL,而是把过期时间放进 value 里。线程 A 发现逻辑时间已过就加锁去重建,其他线程照常返回旧数据,不用等待。
1 |
|
2.3 缓存雪崩:大批 key 同时失效
现象:大量 key 在同一时刻集体过期,或者 Redis 实例直接宕机,所有请求瞬间涌向数据库,数据库顶不住就连锁崩溃。
解法一:TTL 加随机值
1 | // 基础 30 分钟,随机浮动 5 分钟,打散过期时间 |
解法二:多级缓存
本地缓存(Caffeine)+ Redis + 数据库,Redis 挂了还有本地缓存兜底。
解法三:高可用架构
- 主从 + 哨兵,主节点故障自动切换
- Redis Cluster 分片,避免单点
- 服务降级与熔断,数据库压力过大时直接返回兜底数据
2.4 缓存与数据库一致性
经典问题是:更新数据库后,是更新缓存还是删除缓存?
结论:删除缓存,而不是更新缓存。因为更新缓存存在并发写乱序问题 —— 线程 A 先更新库、线程 B 后更新库,但缓存写入顺序可能反过来,导致缓存里是旧数据。删除缓存则没有这个问题,下次读取自然会从数据库加载最新值。
推荐方案是 延迟双删:
1 |
|
追求强一致的话,可以用 Canal 订阅 MySQL binlog,异步删除缓存,把缓存维护逻辑从业务代码里彻底剥离出去。
三、持久化:RDB 与 AOF 怎么选
3.1 RDB(快照)
在指定时间间隔内,把内存数据生成二进制快照文件 dump.rdb。
1 | # 900 秒内至少 1 次修改 |
触发方式:
- 自动:满足
save条件 SAVE—— 主线程执行,会阻塞所有请求,生产环境禁用BGSAVE—— fork 子进程执行,主进程继续服务,推荐
| 优点 | 缺点 |
|---|---|
| 文件紧凑,适合备份与异地容灾 | 会丢失最后一次快照之后的数据 |
| 恢复速度快,远超 AOF | fork 时内存翻倍,数据量大时耗时明显 |
| 最大化 Redis 性能 | 频繁 fork 会影响响应 |
3.2 AOF(追加日志)
把每条写命令追加到日志文件末尾,重启时重新执行一遍来恢复数据。
1 | appendonly yes |
三种刷盘策略必须搞清楚:
| 策略 | 行为 | 数据安全性 | 性能 |
|---|---|---|---|
always |
每条命令都刷盘 | 几乎不丢数据 | 最慢 |
everysec |
每秒刷盘一次 | 最多丢 1 秒数据 | 推荐,默认 |
no |
交给操作系统决定 | 可能丢较多数据 | 最快 |
AOF 重写:文件会越来越大,Redis 会自动触发重写,把多条命令合并成最终状态的等价命令。
1 | auto-aof-rewrite-percentage 100 # 比上次重写后增长 100% |
3.3 混合持久化(推荐)
Redis 4.0 引入,AOF 文件由「RDB 格式的前半段 + AOF 格式的后半段」组成 —— 既保留 RDB 的快速恢复,又保证 AOF 的数据完整性。
1 | aof-use-rdb-preamble yes |
3.4 选型建议
| 场景 | 建议 |
|---|---|
| 纯缓存,丢了能从库里重建 | RDB 就够,甚至可以不持久化 |
| 数据重要,允许丢几秒 | 混合持久化(aof-use-rdb-preamble yes) |
| 数据绝对不能丢 | AOF + appendfsync always,并搭配主从 |
| 只做缓存且追求极致性能 | 关闭持久化 |
四、过期策略与内存淘汰
4.1 过期 key 怎么被删除
Redis 同时使用两种策略来平衡 CPU 和内存:
- 惰性删除:访问 key 时才检查是否过期,过期就删。对 CPU 友好,但过期 key 一直没人访问就一直占内存。
- 定期删除:默认每 100ms 随机抽取一批设置了过期时间的 key 检查并删除,通过限制执行时长来避免阻塞。
4.2 八种淘汰策略
内存达到 maxmemory 上限时触发。
| 策略 | 范围 | 算法 | 适用场景 |
|---|---|---|---|
noeviction |
— | 不淘汰,写请求报错 | 默认,不推荐 |
allkeys-lru |
所有 key | LRU | 纯缓存,最常用 |
volatile-lru |
设了 TTL 的 key | LRU | 想保留持久数据 |
allkeys-lfu |
所有 key | LFU | 有长期热点数据 |
volatile-lfu |
设了 TTL 的 key | LFU | Redis 4.0+ |
allkeys-random |
所有 key | 随机 | 访问分布均匀 |
volatile-random |
设了 TTL 的 key | 随机 | — |
volatile-ttl |
设了 TTL 的 key | 剩余 TTL 短的优先 | 希望优先淘汰快过期的 |
五、生产环境实操
5.1 redis.conf 关键参数
1 | # 内存上限,务必设置,建议物理内存的 60%~70% |
5.2 生产禁用命令
| 命令 | 风险 | 替代方案 |
|---|---|---|
KEYS * |
单线程遍历全量 key,数据量大时直接卡死 | SCAN cursor MATCH pattern COUNT 100 |
FLUSHALL / FLUSHDB |
清空全部数据 | 重命名禁用 |
DEBUG SEGFAULT |
让实例崩溃 | 禁用 |
CONFIG SET |
运行时改配置,风险不可控 | 重命名 |
大 value 的 DEL |
删除大 key 会阻塞 | 用 UNLINK(异步删除) |
1 | # 安全的遍历方式,不会阻塞 |
5.3 慢查询排查
1 | # 查看慢查询日志 |
返回格式说明:
1 | 1) (integer) 12 # 日志 id |
5.4 大 key 治理
big key 的危害:删除阻塞、网络拥塞、集群数据倾斜、迁移失败。
1 | # 找出大 key(离线分析用,比 MEMORY USAGE 遍历更安全) |
拆分方案:
1 | # 一个 10 万字段的 Hash,拆成 100 个 |
六、Spring Boot 整合要点
6.1 依赖与配置
1 | <dependency> |
1 | spring: |
6.2 必须自定义序列化器
默认的 JdkSerializationRedisSerializer 存进去是二进制乱码,既占空间又不可读,换成 JSON:
1 |
|
这样用 redis-cli 直接看缓存内容也是清晰的 JSON,排查问题方便很多。
6.3 缓存注解使用注意
1 |
|
@Cacheable 的三个坑:一、同一个类里方法 A 调方法 B 的 @Cacheable 不生效,因为没走代理;二、一定要加 unless = "#result == null",否则会把 null 缓存进去,后续一直返回 null;三、@Transactional 和 @Cacheable 混用时,可能出现缓存在事务提交前就写入、随后事务回滚的情况,留下脏缓存。这种场景建议把缓存操作放到事务提交之后,用 TransactionSynchronizationManager.registerSynchronization() 在 afterCommit 回调里处理。










