3. 数据结构能力(Redis 的灵魂)
Redis 强在哪?9 种数据结构,每种对应一类业务场景。用对结构,代码少一半,性能高十倍。
| 结构 | 是什么 | 典型场景 | 复杂度 |
| String | 最简单的 KV,value 是字符串/数字 | 缓存/计数器/分布式锁/Session | O(1) |
| Hash | 一个 key 下存多个字段(像对象) | 商品详情/用户信息(比 String 省内存) | O(1) |
| List | 有序列表,两头可插删 | 消息队列/最新动态/操作记录 | O(1) 头尾 |
| Set | 无序集合,自动去重 | 标签/共同好友/去重 | O(1) |
| ZSet | 有序集合,按 score 排序 | 排行榜/延迟队列/Top N | O(logN) |
| Bitmap | 位图,1 个用户 1 bit | 签到/在线状态/布隆过滤器 | O(1) |
| HyperLogLog | 基数估算(去重计数) | UV 统计(亿级去重) | O(1) |
| Stream | 消息流(5.0+) | 消息队列/事件流 | O(1) |
| Geo | 地理坐标 | 附近的人/门店/距离 | O(logN) |
💻 5 大基础结构 redis-cli 实操
① String — 缓存 / 计数器 / 分布式锁
127.0.0.1:6379> SET user:1001 '{"name":"张三","age":25}' # 缓存用户
OK
127.0.0.1:6379> GET user:1001
"{\"name\":\"张三\",\"age\":25}"
127.0.0.1:6379> INCR article:88:likes # 点赞 +1(原子操作)
(integer) 1
127.0.0.1:6379> INCR article:88:likes # 再点一次
(integer) 2
127.0.0.1:6379> SET lock:order:10086 "node-1" NX EX 30 # 分布式锁:不存在才设,30秒过期
OK # 加锁成功
127.0.0.1:6379> SET lock:order:10086 "node-2" NX EX 30 # 另一台机器再试
(nil) # 加锁失败,已被占用
② Hash — 对象存储(比 String 省内存)
# 存商品详情:一个 key 下多个字段
127.0.0.1:6379> HSET product:10086 name "iPhone" price 6999 stock 100
(integer) 3
127.0.0.1:6379> HGET product:10086 price # 只取价格
"6999"
127.0.0.1:6379> HINCRBY product:10086 stock -1 # 库存 -1(原子)
(integer) 99
127.0.0.1:6379> HGETALL product:10086 # 取全部
name
"iPhone"
price
"6998"
stock
"99"
③ ZSet — 排行榜(核心能力)
# 游戏积分排行榜:ZADD 加成员+分数,自动按分数排序
127.0.0.1:6379> ZADD rank:game 8500 "玩家A" 9200 "玩家B" 7800 "玩家C"
(integer) 3
127.0.0.1:6379> ZREVRANGE rank:game 0 2 WITHSCORES # Top3(从高到低)
"玩家B"
"9200"
"玩家A"
"8500"
"玩家C"
"7800"
127.0.0.1:6379> ZINCRBY rank:game 500 "玩家A" # 玩家A加分
"9000"
127.0.0.1:6379> ZRANK rank:game "玩家A" # 玩家A排名(从0开始)
(integer) 0 # 第1名!
④ List — 消息队列(轻量)
# 生产者:LPUSH 从左边塞消息
127.0.0.1:6379> LPUSH task:queue "发邮件:10086" "发短信:10087"
(integer) 2
# 消费者:BRPOP 从右边取(阻塞式,没消息就等)
127.0.0.1:6379> BRPOP task:queue 0 # 0 = 一直等
"task:queue"
"发邮件:10086" # 先进先出
⑤ Bitmap — 签到(极致省内存)
# 用户1001,6月18日签到(第18天 → offset 18)
127.0.0.1:6379> SETBIT sign:1001:202606 18 1
(integer) 0
127.0.0.1:6379> GETBIT sign:1001:202606 18 # 查18号签了没
(integer) 1
127.0.0.1:6379> BITCOUNT sign:1001:202606 # 本月签到几天
(integer) 1
# 1亿用户 × 31天 = 31亿 bit ≈ 387MB(用 String 存要几十GB)
选结构的口诀:
• 存对象/多个字段 → Hash(比 String 省 50% 内存)
• 要排序/排行榜 → ZSet(自带排序,不用查 DB ORDER BY)
• 要去重 → Set(小数据)或 HyperLogLog(大数据,12KB 估算亿级)
• 要队列 → Stream(5.0+,支持消费组)或 List(简单场景)
• 要省内存的签到/在线 → Bitmap(1 用户 1 bit)
7. 生产上怎么用(3 个典型场景,带界面演示)
🎬 场景 1:分布式锁 — 防止库存超卖
现象:秒杀场景,10 件商品,1000 人同时下单。如果不加锁,3 台机器各自查到库存=10,各卖 10 件 → 超卖 20 件。需要分布式锁:同一时刻只有 1 个请求能扣库存。
Redis 监控面板:分布式锁实时状态
🔴 Redis
lock:* (分布式锁)
秒杀进行中 ▾
lock:stock:10086
TTL: 28s
持有者: node-2 🔒
lock:stock:10087
已释放 ✓
下一个等待中...
实现(Redisson,生产推荐):
// Redisson 分布式锁(比手写 SETNX 可靠:自动续期/可重入/防死锁)
RLock lock = redissonClient.getLock("lock:stock:" + productId);
try {
if (lock.tryLock(3, 30, TimeUnit.SECONDS)) { // 等3秒,锁30秒自动释放
int stock = Integer.parseInt(redis.opsForValue().get("stock:" + productId));
if (stock > 0) {
redis.opsForValue().decrement("stock:" + productId);
// 创建订单...
}
}
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
为什么用 Redisson 不用手写 SETNX:① 自动续期(业务没执行完锁不会过期)② 可重入(同线程可多次获取)③ 释放锁用 Lua 脚本(原子性,防误删)
🎬 场景 2:排行榜 — 实时游戏积分榜
现象:游戏要实时显示积分 Top 100,每周一清零重排。如果用 MySQL ORDER BY,100 万玩家排序要几秒;用 Redis ZSet,毫秒级返回。
Redis 排行榜实时面板
🔴 Redis
rank:game (ZSet)
Top 5 ▾
ZREVRANGE rank:game 0 4 WITHSCORES
1
王者归来
12,450
2
无敌战神
11,820
3
深夜刺客
10,950
4
咖啡程序员
9,800
5
新手小白
9,120
实现:
// 玩家加分(实时更新排行榜)
redis.opsForZSet().incrementScore("rank:game", playerId, score);
// 查 Top 100
Set top100 = redis.opsForZSet().reverseRange("rank:game", 0, 99);
// 查我的排名
Long myRank = redis.opsForZSet().reverseRank("rank:game", myId);
// 每周一清零:直接删 key 重建
redis.delete("rank:game");
为什么不用 MySQL:100 万行 ORDER BY 要 2-5 秒,Redis ZSet 的 ZREVRANGE 是 O(logN+M),2ms 返回。
ZSet 底层是跳表 + 哈希表,天然有序。
🎬 场景 3:API 限流 — 每分钟 100 次
现象:对外 API 要限流"每个用户每分钟最多调 100 次"。3 台机器各自计数没意义(加起来超了),需要 Redis 集中计数。
Redis 限流计数器面板
🔴 Redis
rate:* (限流)
当前分钟 ▾
rate:user:10086
100/100
🚫 已限流
rate:user:10087
42/100
✅ 正常
rate:user:10088
89/100
⚠️ 接近上限
实现(计数器法):
public boolean allowRequest(String userId, int limit) {
String key = "rate:" + userId;
Long count = redis.opsForValue().increment(key); // +1(原子)
if (count == 1) {
redis.expire(key, 60, TimeUnit.SECONDS); // 第一次设过期,60秒后自动清零
}
return count <= limit; // ≤100 放行,>100 拒绝
}
进阶:① 滑动窗口限流(用 ZSet,更精准)② 令牌桶(Redisson RateLimiter,支持突发流量)③sentinel/guava 限流框架
8. 冷热数据管理(预热 / 筛选 / 分层)
为什么要管冷热数据
Redis 是内存数据库,内存贵且有限。把所有数据都塞 Redis = 烧钱;把冷数据也放 Redis = 浪费内存 + 命中率低。冷热分离是 Redis 生产化的核心能力:热数据放 Redis 秒级响应,冷数据归档到 MySQL/对象存储省内存。这一章讲清楚:怎么识别热数据、怎么预热、冷数据怎么处理。
🌡️ 什么是冷热数据
| 分类 | 定义 | 访问频率 | 放哪 | 典型例子 |
| 🔥 热数据 | 频繁访问,对延迟敏感 | 每秒/每分钟访问 | Redis | 首页商品/热搜词/在线用户Session/秒杀库存 |
| 🍵 温数据 | 偶尔访问,可容忍百ms | 每天访问几次 | Redis(设TTL)或 MySQL+本地缓存 | 用户最近7天订单/商品详情/配置项 |
| ❄️ 冷数据 | 很少访问,秒级可接受 | 每月/几乎不访问 | MySQL / 对象存储 / 归档表 | 历史订单/日志/3个月前的消息/已结束活动 |
冷热判断的一句话标准:"这个数据如果不在 Redis,用户能不能忍 100ms?" 能忍 → 冷数据,放 MySQL;不能忍 → 热数据,放 Redis。秒杀库存、热搜、首页 Banner 这种不能忍的才进 Redis,其他该放 MySQL 放 MySQL。
🔍 怎么识别/筛选热数据(4 种方法)
热数据不是拍脑袋定的,要用数据说话。生产里有 4 种识别方法,从简单到专业:
方法 ①:业务侧直接标注(最简单,凭经验)
// 启动时把已知的热数据批量加载到 Redis
@PostConstruct
public void preloadHotData() {
// 1. 首页 Banner(运营配置,肯定是热的)
List<Banner> banners = bannerMapper.findActive();
for (Banner b : banners) {
redis.opsForValue().set("banner:" + b.getId(), toJSON(b), 1, TimeUnit.HOURS);
}
// 2. 秒杀商品库存(大促前运营指定)
List<Long> seckillIds = seckillMapper.findOngoingIds();
for (Long id : seckillIds) {
Integer stock = productMapper.getStock(id);
redis.opsForValue().set("stock:" + id, String.valueOf(stock));
}
}
适用场景:数据量小、热数据明确(首页/秒杀/热搜)。缺点:靠人判断,可能漏掉隐性热点。适合项目初期。
方法 ②:统计访问频次(最常用,数据驱动)
给每个 key 加访问计数,定期统计 TOP N 就是热数据。两种实现:
// 方式A:用 Redis ZSet 统计 key 访问次数(轻量)
public Object getWithStat(String key) {
Object val = redis.opsForValue().get(key);
if (val != null) {
// 命中就 +1 访问计数
redis.opsForZSet().incrementScore("__hot_keys__", key, 1);
}
return val;
}
// 定时任务:每天凌晨统计昨天的 TOP 100 热key,加入预热清单
@Scheduled(cron = "0 0 2 * * ?")
public void analyzeHotKeys() {
Set<String> top100 = redis.opsForZSet().reverseRange("__hot_keys__", 0, 99);
// 存到配置表,下次启动时预热这些 key
hotKeyMapper.batchSave(top100);
redis.delete("__hot_keys__"); // 清空,重新统计
}
适用场景:数据量大、热点不明显、需要数据支撑决策。这是生产最常用的方法。缺点:统计本身也消耗一点 Redis 性能。
方法 ③:Redis 自带 hotkey 发现(redis-cli --hotkeys)
# Redis 4.0+ 自带 hotkey 发现功能(基于 LFU 计数)
$ redis-cli --hotkeys
# 输出示例(按访问频次排序)
Sampling 1000000 keys for hot keys...
---------------------------------------
hotKey found: 1
[1] product:10086 (accesses: 82341) # ← 这就是热 key!
hotKey found: 2
[2] user:session:1001 (accesses: 45892)
hotKey found: 3
[3] rank:game (accesses: 31204)
hotKey found: 4
[4] banner:home (accesses: 18923)
hotKey found: 5
[5] config:global (accesses: 15402)
---------------------------------------
# 注意:要先开启 maxmemory-policy = allkeys-lfu 才能用
PRO TIP
redis-cli --hotkeys 基于 LFU 计数器,不需要额外代码,Redis 自己就在记录每个 key 的访问频次。前提是淘汰策略要配成 allkeys-lfu 或 volatile-lfu。这是发现热点最快的方法,排查"哪个 key 打爆了 Redis"时第一时间用它。
方法 ④:监控采样 + 链路分析(最专业)
用 redis-faina(Twitter 开源)或 RedisInsight 分析 MONITOR 采样的命令流,统计热点 key/热点命令。适合大厂精细运营。
# redis-faina 分析 MONITOR 抓到的命令流
$ redis-cli MONITOR | redis-faina --timeout 30
Overall Top Keys
=======================================
1. product:10086 (18234 calls, 12.4%) # ← 占总命令12%!典型热key
2. user:1001 (8421 calls, 5.7%)
3. session:abc123 (6234 calls, 4.2%)
4. cart:10086 (3120 calls, 2.1%)
5. stock:999 (2890 calls, 2.0%)
Overall Top Commands
=======================================
1. GET (82341 calls, 56.2%)
2. SET (32104 calls, 21.9%)
3. EXPIRE (15402 calls, 10.5%)
🔥 怎么预热(启动时加载热数据)
预热 = 服务启动时把热数据提前加载到 Redis,避免冷启动时大量请求直接打 DB。有 3 种策略:
| 策略 | 怎么做 | 适合场景 | 缺点 |
| 全量预热 | 启动时把所有"可能热"的数据全灌进 Redis | 数据量小(<10万)/ 活动前准备 | 启动慢,可能灌了一堆冷数据 |
| 增量预热 | 只预热统计出来的 TOP N 热 key | 数据量大 / 日常运行 | 需先有统计数据 |
| 懒加载 | 不预热,第一次访问时查 DB 回填 Redis | 数据量大 + 冷启动可接受 | 首批用户慢,可能击穿 |
生产推荐组合:增量预热 + 懒加载兜底。启动时预热统计出的 TOP 100 热 key(覆盖大部分流量),其余的第一次访问时懒加载回填。不要全量预热——大部分数据根本不会被访问,灌进去纯浪费内存。
🎬 场景 1:大促前预热秒杀商品(全量预热)
背景:双 11 零点秒杀,预计 100 万人抢 10 款商品。如果零点才开始查 DB 加载商品信息和库存到 Redis,前几秒 DB 会被打爆。需要提前预热:活动开始前 1 小时把商品数据、库存、活动配置全灌进 Redis。
预热过程监控面板
🔴 Redis
预热进度
热 key 榜
大促前 1 小时 ▾
预热任务清单(分批执行):
✓ 批次1: 秒杀商品详情 ×10
完成 10/10
✓ 批次2: 库存数据 ×10
完成 10/10
⏳ 批次3: 活动配置 ×5,000
4,201/5,000
○ 批次4: 用户优惠券 ×50,000
等待中
已加载的 TOP 5 热 key:
1stock:10086预估 50万 QPS
2product:10086预估 30万 QPS
3seckill:config预估 20万 QPS
4banner:home预估 8万 QPS
5coupon:rules预估 5万 QPS
预热代码(分批加载,避免一次性打爆 DB):
@Scheduled(cron = "0 0 23 * * ?") // 大促前一天23点执行
public void preheatForSeckill() {
List<Long> seckillIds = seckillMapper.findTomorrowIds();
// 分批加载,每批 100 条,避免一次性查 DB 几万条把 DB 搞挂
Lists.partition(seckillIds, 100).forEach(batch -> {
for (Long id : batch) {
// 1. 商品详情(Hash,TTL 2小时)
Product p = productMapper.findById(id);
Map<String, String> fields = toBeanMap(p);
redis.opsForHash().putAll("product:" + id, fields);
redis.expire("product:" + id, 2, TimeUnit.HOURS);
// 2. 库存(String,不设TTL,活动结束才删)
Integer stock = stockMapper.getStock(id);
redis.opsForValue().set("stock:" + id, String.valueOf(stock));
}
try { Thread.sleep(100); } catch (Exception e) {} // 每批间隔100ms,别把DB压垮
});
log.info("预热完成,共加载 {} 个秒杀商品", seckillIds.size());
}
🎬 场景 2:日常运行时热点 key 发现与自动保护
背景:日常运行中,某个商品突然爆火(比如被网红带货),QPS 从 100 飙到 5 万,这个 key 成了热点,单分片被打爆。需要自动发现热点 key + 自动加本地缓存保护。
热点 key 实时监控面板(redis-cli --hotkeys)
🔴 Redis
热点 key 实时榜
冷 key 归档
最近 5 分钟 ▾
🔍 redis-cli --hotkeys 采样结果(按 LFU 访问计数排序):
1
product:10086
82,341 次
⚠️ 爆热!已触发本地缓存
2
product:10087
45,892 次
⚠️ 接近阈值
3
banner:home
18,923 次
✅ 正常热点
4
config:global
15,402 次
✅ 正常
5
user:profile:9999
3 次
❄️ 冷 key,建议归档
热点 key 自动保护(本地缓存兜底):
// 1. 定时扫描热点 key(每分钟)
@Scheduled(fixedRate = 60000)
public void scanHotKeys() {
// 用 OBJECT FREQ 命令查 key 的 LFU 访问频次(Redis 4.0+)
for (String key : scanAllKeys()) {
Long freq = redis.execute(c -> c.objectFreq(key));
if (freq != null && freq > 50000) {
// 2. 超过5万次 → 标记为热点,加入本地缓存白名单
hotKeyManager.markHot(key);
log.warn("发现热点 key: {},访问 {} 次,已启用本地缓存保护", key, freq);
}
}
}
// 3. 读取时优先走本地缓存(Caffeine),命中就不打 Redis
public Object get(String key) {
// 如果是热点 key,先查本地缓存
if (hotKeyManager.isHot(key)) {
Object local = caffeine.getIfPresent(key);
if (local != null) return local; // 本地命中,不打 Redis
}
// 非热点或本地未命中 → 查 Redis
Object val = redis.opsForValue().get(key);
if (val != null && hotKeyManager.isHot(key)) {
caffeine.put(key, val); // 回填本地缓存(TTL 10秒,防数据太旧)
}
return val;
}
🎬 场景 3:冷数据自动归档(TTL + 淘汰)
背景:用户消息表 3 个月积累了 5000 万条,全放 Redis 要 20GB,成本太高。实际上 3 个月前的消息几乎没人看。方案:近 7 天消息放 Redis,7-90 天放 MySQL,90 天以上归档到冷存储。
🔥 0-7天
→
Redis
|
🍵 7-90天
→
MySQL 热库
|
❄️ >90天
→
MySQL 归档表/对象存储
冷热分层:热数据秒级响应,冷数据省内存省钱
冷数据归档任务面板
🔴 Redis
冷 key 归档
热 key 榜
归档任务执行中 ▾
归档策略配置:
hot_ttl: 7天 → Redis (access > 100/天)
warm_ttl: 90天 → MySQL 热库 (1-100次/天)
cold_archive: >90天 → MySQL 归档表 (<1次/天)
归档日志(最近 5 条):
[02:15:03] msg:user:10086 → MySQL(msg_archive_202603) ✓
[02:15:03] msg:user:10087 → MySQL(msg_archive_202603) ✓
[02:15:04] msg:user:10088 → MySQL(msg_archive_202603) ✓
[02:15:04] msg:user:10089 → 跳过(仍在访问)
[02:15:05] msg:user:10090 → MySQL(msg_archive_202603) ✓
归档代码(定时任务 + TTL 配合):
// 1. 写入时设 TTL,让 Redis 自动清理冷数据
public void saveMessage(Long userId, Message msg) {
redis.opsForValue().set("msg:" + userId, toJSON(msg), 7, TimeUnit.DAYS);
// 同时写 MySQL(持久化)
messageMapper.insert(msg);
}
// 2. 定时归档:把 Redis 里7天未访问的 key 迁到 MySQL 归档表
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void archiveColdData() {
// SCAN 扫描所有 msg:* key(别用 KEYS!)
ScanOptions opts = ScanOptions.scanOptions().match("msg:*").count(1000).build();
try (Cursor<String> cursor = redis.scan(opts)) {
while (cursor.hasNext()) {
String key = cursor.next();
// 查空闲时间(多久没被访问)
Long idleTime = redis.execute(c -> c.objectIdletime(key));
if (idleTime != null && idleTime > 7 * 24 * 3600) {
// 空闲超过7天 → 归档到 MySQL,删除 Redis
String val = redis.opsForValue().get(key);
archiveMapper.insert(parseMsg(val));
redis.delete(key);
}
}
}
}
// 3. 读取时按层级查:Redis → MySQL热库 → MySQL归档表
public Message getMessage(Long userId) {
// 先查 Redis(热数据)
String val = redis.opsForValue().get("msg:" + userId);
if (val != null) return parseMsg(val);
// 再查 MySQL 热库(温数据)
Message msg = messageMapper.findByUserId(userId);
if (msg != null) {
redis.opsForValue().set("msg:" + userId, toJSON(msg), 7, TimeUnit.DAYS);
return msg;
}
// 最后查归档表(冷数据,慢但省内存)
return archiveMapper.findByUserId(userId);
}
📊 冷热数据管理实践总结
| 阶段 | 做什么 | 工具/命令 | 频率 |
| 识别 | 找出哪些是热数据 | redis-cli --hotkeys / ZSet 计数 / redis-faina | 每天/实时 |
| 预热 | 启动/活动前加载热数据 | @PostConstruct / @Scheduled 分批加载 | 启动时/活动前 |
| 保护 | 热点 key 加本地缓存兜底 | Caffeine + 热点检测 + 自动降级 | 实时 |
| 归档 | 冷数据从 Redis 迁到 MySQL | SCAN + OBJECT IDLETIME + 定时任务 | 每天凌晨 |
| 淘汰 | Redis 满了自动淘汰冷 key | maxmemory + allkeys-lfu 策略 | 自动 |
PRO TIP
冷热分离的核心不是技术,是"舍得删"。很多团队 Redis 越来越大、越来越慢,就是因为"万一以后用到"不肯删冷数据。生产原则:设 TTL 让 Redis 自动清理 + 定时归档 + LFU 淘汰策略兜底。冷数据放 MySQL 查询慢一点(100ms vs 1ms),但 Redis 内存省下来能扛更多热数据,整体命中率反而更高。