主题
界面
主题
从本节起进入缓存与中间件。第一个问题是:为什么不用 HashMap 缓存,而要用 Redis?
HashMap:单机、进程内、重启就没了、多实例不共享 ✗
Redis: 独立进程、多实例共享、可持久化、有数据结构 ✓✓★ 本节先讲"Redis 能装什么",后面四节讲"怎么用它解决真问题" ✓
HashMap / ZSet 的底层结构吗?(《迭代器与集合遍历陷阱》、《树、堆与哈希表》)✓先看一个"用错数据结构"的例子 ✗:
需求:实时排行榜(按销量取前 10)
✗ 用 String 存 JSON 数组 → 每次都要全量读出、自己排序 → O(n log n) ✗
✓ 用 ZSet → ZREVRANGE rank 0 9 → O(log n + 10) ✓✓★ 本节的核心是"按需求选结构" ✓✓:
| 需求 | 结构 |
|---|---|
| 缓存对象 / 计数 | String ✓ |
| 队列 / 最新 N 条 | List ✓ |
| 去重 / 共同好友 | Set ✓ |
| 排行榜 / 延时队列 | ZSet ✓✓ |
| 类型 | 结构 | 核心命令 | 典型用途 |
|---|---|---|---|
| String ★★★ | 字符串/数字/二进制 | SET GET INCR SETEX | 缓存、计数器、分布式锁 |
| Hash ★★★ | 字段-值映射 | HSET HGET HGETALL | 对象缓存、购物车 |
| List ★★ | 双向链表 | LPUSH RPUSH LPOP LRANGE | 队列、栈、最新 N 条 |
| Set ★★ | 无序去重集合 | SADD SMEMBERS SINTER | 去重、共同好友、抽奖 |
| ZSet ★★★ | 带分数的有序集合 | ZADD ZRANGE ZRANK | 排行榜、延时队列、限流 |
★ 本节的实测(MiniRedisLesson 的输出,标注了对应的真实命令)✓:
SET user:1:name alice → GET 返回 alice
SET session:abc user-1 EX 60 → TTL 剩余 60 秒
HSET product:1 name 机械键盘 / price 399 / stock 50
HGETALL product:1 → {name=机械键盘, price=399, stock=50}
RPUSH queue:tasks 任务1 任务2 任务3 → [任务1, 任务2, 任务3]
LPOP queue:tasks → 取出 任务1(先进先出 = 队列)✓
SADD user:1:tags 技术 读书 技术 → [技术, 读书](重复的只存一份 ✓)
ZADD rank:sales 9800 商品A / 15600 商品B / 7300 商品C / 21000 商品D
ZRANGE rank:sales 0 -1 WITHSCORES → 按分数升序输出 ✓这是缓存对象时最常见的抉择 ✓:
| 维度 | Hash | String(存 JSON) |
|---|---|---|
| 改单个字段 ★★★ | 只更新那个字段 ✓✓ | 要读出整个 JSON、改完写回 ✗ |
| 更新粒度 | 字段级 ✓ | 整体 ✗ |
| 序列化开销 | 无 ✓ | 每次都要序列化/反序列化 ✗ |
| 内存效率 | 小 Hash 有压缩(ziplist)✓ | 整体存,可能更省 ✓ |
| 适合 | 只改某几个字段的对象 ✓✓ | 整体读写、不关心中间状态 ✓ |
★ 判断口诀 ✓✓:
"会不会只更新其中一部分字段?" ——会 → Hash;不会 → String ✓
三个原因 ✓:
| # | 原因 | 说明 |
|---|---|---|
| ① 纯内存操作 ★★★ | 没有磁盘 IO ✓(持久化是异步的) | |
| ② 单线程模型 ★★★ | 没有锁竞争、没有上下文切换 ✓✓ | |
| ③ IO 多路复用 ★★ | 一个线程管理大量连接(epoll)✓ |
★★ "单线程"常被误解 ✓✓:
它指的是"命令执行是单线程串行" ✓
不是"Redis 只有一个线程" ——持久化、集群同步、异步删除都有独立线程 ✓
而命令串行带来的最大好处是:所有命令天然是"原子"的 ✓✓ 这就是
INCR、SETNX能直接用来做计数器和锁的原因 ✓✓
★ 本节的性能实测(节选,10 万次写入) ✓:
String(SET): 38 ms
ZSet(ZADD): 19 ms ← ZADD 本身是哈希表写入,排序成本在 ZRANGE 时才产生(本实现惰性排序)★★ 但这不是Redis 的真实性能 ✗ ——必须说清三点 ✓:
| 差异 | 说明 |
|---|---|
| 没有网络往返 ✗ | 真实 Redis 一次命令要 1 次 RTT(本机约 0.1ms,跨机房约 1ms) |
| 不是单线程 ✗ | 这里用并发容器,真实 Redis 串行执行 |
| 没有协议解析 ✗ | RESP 的编解码本身也有成本 |
★ 所以真实项目里的优化重点是"减少 RTT" ✓✓:
| 手段 | 效果 |
|---|---|
| pipeline(批量发送) ★★★ | N 次命令只花 1 次 RTT ✓✓ |
| Lua 脚本 ★★ | 多条命令在服务端一次执行 ✓ |
批量命令(MGET / MSET)★ | 一次取多个 key ✓ |
★★ 一条算账 ✓✓:10 万次单条命令 × 1ms RTT = 100 秒 ✗ 而用 pipeline 分 100 批:100 × 1ms + 服务端执行时间 ≈ 几秒 ✓✓
Redis 用两种策略配合 ✓:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 ★★ | 访问时才发现过期,顺手删掉 | 省 CPU ✓ | 不被访问的 key 会一直占内存 ✗ |
| 定期删除 ★★★ | 每秒 10 次,每次随机抽查 20 个 key | 主动清理 ✓ | 不能扫全量(会阻塞)→ 抽样 ✗ |
★ 本节的实测 ✓:
SETEX code:... 123456 1 → 立即 GET:123456
等 1.1 秒后 GET:null(返回 null)✓ ← 惰性删除生效
另外 1000 个 key 也过期了,但没人访问它们
执行定期删除(抽样扫描):清理了 1000 个 ✓ ← 定期删除生效★★ 由此推出一条重要结论 ✓✓:
即使设了 TTL,内存也不会立刻释放 ✗
所以必须配置"内存淘汰策略"(
maxmemory-policy) ✓✓
★ 八种淘汰策略里,最常用的三种 ✓:
| 策略 | 淘汰谁 |
|---|---|
allkeys-lru ★★ | 所有 key 里最久没用的(纯缓存场景推荐)✓ |
volatile-lru ★ | 只从"设了过期时间的 key"里淘汰 |
noeviction ⚠️ | 不淘汰,写入直接报错(当数据库用时选它)✓ |
| 类型 | 一句话 | 量级感受 |
|---|---|---|
| HyperLogLog ★★ | 估算基数(UV),误差 0.81% | 12 KB 估算上亿 UV ✓✓ |
| Bitmap ★★ | 位图,一 bit 一个状态 | 1 亿用户签到一年 = 4.5 GB ✓ |
| Stream ★ | 带消费组的消息队列 | 可替代轻量 MQ ✓ |
★ HyperLogLog 的取舍 ✓:
它不存原始数据,只存"估算结构" ✓
所以不能"列出具体有哪些用户" ✗ ——只能回答"大概有多少个" ✓✓
用于 UV 统计这种"只想看数字"的场景 ✓
★ Bitmap 的典型用法 ✓✓:
签到:SETBIT sign:20260922 <userId> 1 ← 一个用户一天只占 1 bit
统计7天连续签到:对 7 个 bitmap 做 AND ← 一次位运算搞定 ✓✓| 想做什么 | 命令 |
|---|---|
| 缓存对象 | SET / GET(JSON)或 HSET / HGETALL |
| 计数 | INCR / DECR / INCRBY |
| 带过期的缓存 | SET key value EX 60 |
| 只有不存在才设置(锁) | SET key value NX EX 30(《Redis 分布式锁》) |
| 批量取 | MGET k1 k2 k3 |
| 排行榜 | ZADD / ZREVRANGE / ZRANK |
| 队列 | LPUSH + BRPOP(阻塞取) |
| 延时任务 | ZSet(score 存执行时间戳) ✓✓ |
| 限流 | INCR + EXPIRE 或 ZSet 滑动窗口 ✓ |
| 模糊查 key | SCAN(不是 KEYS)✗✗ |
★★ 最后一条是生产禁忌 ✓✓:
KEYS *会遍历所有 key 并阻塞整个 Redis ✗✗生产环境一律用
SCAN(游标分批) ✓✓
FLUSHALL/FLUSHDB同理 —— 要么禁用,要么改名 ⚠️
# 方式一:Hash(可以只改某个字段)
HSET product:1 name 机械键盘 price 399
HINCRBY product:1 stock -1 ← 只改库存 ✓
# 方式二:String + JSON(整体读写)
SET product:1 '{"name":"机械键盘","price":399}'
GET product:1# 下单 30 分钟后自动取消
ZADD delay:cancel <当前时间戳+1800> ORD-123 ← score 是"该执行的时间"
# 后台轮询:取出所有到点的
ZRANGEBYSCORE delay:cancel 0 <当前时间戳> LIMIT 0 100★ 这就是"延时队列"的最小实现 ✓✓
LPUSH + BRPOP 看起来很香,但它:
✗ 没有消费确认(取出来就没了,处理失败就丢了)
✗ 没有重试
✗ 没有多消费组
→ 要这些能力就上 RabbitMQ / Kafka(S4-012)✓代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/ —— MiniRedis(最小实现)+ MiniRedisLesson(演示与实测)
运行:
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.MiniRedisLesson★ 为什么要用"自己实现的 MiniRedis" ✓✓:
本机没有安装 Redis ——与其伪造数据,不如实现一个"行为一致的模型" ✓
每条演示都标注了对应的真实 Redis 命令,语义一致,可直接迁移 ✓✓
它的局限也在代码注释里写明了(无网络协议、无内存淘汰、无持久化)✗
实测结论 ✓:
| # | 结论 |
|---|---|
| ① | 五种结构各司其职:缓存用 String/Hash、排队用 List、去重用 Set、排序用 ZSet ✓ |
| ② | 过期是"惰性 + 定期"双策略,所以设了 TTL 内存也不会立刻释放 ✓✓ |
| ③ | 真实场景的瓶颈在 RTT,不在命令本身 → 用 pipeline / Lua ✓✓ |
SETEX 并观察 TTL:理解"过期时间会被重置" ✓;KEYS * 和 SCAN 对比(在有真实 Redis 的环境):体会阻塞风险 ⚠️;HGETALL:观察网络传输量(改 HMGET 只取需要的字段)★;LRANGE key 0 -1:观察大 List 的返回量(应该用 LRANGE key 0 9)★。| # | 错误 | 说明 |
|---|---|---|
| 1 | 生产用 KEYS * ✗✗ | 阻塞整个 Redis ✓ |
| 2 | 以为"设了 TTL 内存就立刻释放" ✗✗ | 惰性 + 定期(5.4)✓ |
| 3 | 不配淘汰策略 ✗✗ | 内存满了写入直接失败 ✓ |
| 4 | 把大对象(整张表)塞进一个 key ✗✗ | 大 key 会阻塞、难删除 ✓ |
| 5 | 把 List 当正经 MQ ✗ | 没有确认/重试/消费组 ✓ |
| 6 | 一条条发命令,不做 pipeline ✗ | RTT 成为瓶颈 ✓ |
| 7 | 用 HyperLogLog 后想"列出具体用户" ✗ | 它只存估算结构 ✓ |
目标:把"按需求选结构"变成本能。
步骤:
MiniRedisLesson,确认五种结构的输出与真实命令的对应 ✓;LPUSH + LTRIM)★★;验收方式:能对任意一个需求说出"该用哪种结构、为什么"。
扩展任务:设计一个"附近的人"的最小方案:
GEO 系列命令(底层也是 ZSet)✓;GEOADD 写入位置、GEOSEARCH 查附近 ✓;需求:商品详情页缓存
- 商品有 20 个字段,其中"库存"会被高频更新
- 其他字段几乎不变用 String(JSON) 还是 Hash?为什么? 5. (编程)用 ZSet 设计一个"滑动窗口限流":限制每用户每分钟最多 100 次请求(提示:score 存时间戳,ZREMRANGEBYSCORE 清窗口外的)。
HINCRBY 只改它, 而 String 要读出整个 JSON、改完写回(还会增加并发覆盖的风险)✓✓ZREMRANGEBYSCORE limit:<user> 0 <now-60000> ← 清掉 1 分钟前的记录
ZCARD limit:<user> ← 看还剩多少
ZADD limit:<user> <now> <请求ID> ← 记录本次KEYS / FLUSHALL ★★。《缓存穿透、击穿与雪崩》:缓存失效时会发生什么,以及怎么接住。