Redis 实战复盘与面试高频考点详解(基于黑马课程的基础和实战篇展开、不涉及高级和原理篇)
基于黑马点评项目的 Redis 全场景实战复盘,涵盖短信登录、商户缓存、秒杀优化、分布式锁、消息队列、达人探店、好友关注、附近商户、签到统计等核心模块,同时包含后端面试中 Redis 高频考点的深度解析。
项目地址:HeiMaDianPing | 技术栈:SpringBoot 2.5.7 + MyBatis-Plus + Redis 6.2 + Redisson + Lua
目录
-
[开篇:项目架构与 Redis 全景](#开篇:项目架构与 Redis 全景)
-
[短信登录 ------ Redis 替代 Session 实现分布式会话](#短信登录 —— Redis 替代 Session 实现分布式会话)
-
[商户查询缓存 ------ 缓存穿透/雪崩/击穿三连击](#商户查询缓存 —— 缓存穿透/雪崩/击穿三连击)
-
[优惠券秒杀 ------ 从单机锁到分布式锁的演进](#优惠券秒杀 —— 从单机锁到分布式锁的演进)
-
[Redisson ------ 企业级分布式锁的完整方案](#Redisson —— 企业级分布式锁的完整方案)
-
[秒杀优化 ------ Lua + Redis Stream 异步秒杀](#秒杀优化 —— Lua + Redis Stream 异步秒杀)
-
[达人探店 ------ SortedSet 实现点赞排行榜](#达人探店 —— SortedSet 实现点赞排行榜)
-
[好友关注 ------ Set 交集与 Feed 流推送](#好友关注 —— Set 交集与 Feed 流推送)
-
[附近商户 ------ GeoHash 实现 LBS 功能](#附近商户 —— GeoHash 实现 LBS 功能)
-
[用户签到 ------ BitMap 统计连续签到](#用户签到 —— BitMap 统计连续签到)
-
[UV 统计 ------ HyperLogLog 海量去重计数](#UV 统计 —— HyperLogLog 海量去重计数)
-
[项目中的 Redis 数据结构使用总结](#项目中的 Redis 数据结构使用总结)
一、开篇:项目架构与 Redis 全景
1.1 项目架构
手机端/浏览器
│
▼
Nginx(反向代理 + 动静分离)
│
▼
Tomcat 集群(负载均衡)
│
├──── Redis 集群(缓存层)← 命中直接返回
│ │
│ └── 缓存 miss ──→ MySQL 集群(持久化层)
Nginx 的核心作用:
-
七层反向代理,基于 HTTP 协议将请求负载均衡到下游 Tomcat
-
静态资源服务器(前端工程),做到动静分离,降低 Tomcat 压力
-
单台 4核8G 的 Tomcat 约支撑 1000 并发,经 Nginx 分流后由集群承载整体吞吐
Redis 在架构中的定位:
-
企业级 MySQL(32-64G 内存 + SSD)大约支撑 4000-7000 并发,上万并发就会打满 CPU/磁盘
-
Redis 作为缓存层,大幅降低 MySQL 的读压力,同时支撑秒杀等高并发写场景
1.2 项目中 Redis 的应用全景图
| 模块 | Redis 数据结构 | 解决的核心问题 |
|---|---|---|
| 短信登录 | Hash | 分布式 Session 共享 |
| 商户缓存 | String | 缓存穿透/雪崩/击穿 |
| 全局唯一ID | String (INCR) | 分布式 ID 生成 |
| 秒杀下单 | String + Set + Lua | 原子性扣减库存 + 一人一单 |
| 分布式锁 | String (SETNX) | 集群环境下的互斥 |
| Redisson | Hash + Pub/Sub | 可重入锁 + 看门狗 + 联锁 |
| 秒杀优化 | Stream | 异步下单 + 消息队列 |
| 达人探店 | SortedSet | 点赞排行榜 |
| 好友关注 | Set | 共同关注(交集) |
| Feed 流 | SortedSet | 滚动分页 + 时间排序 |
| 附近商户 | GEO | LBS 地理搜索 |
| 用户签到 | BitMap | 连续签到统计 |
| UV 统计 | HyperLogLog | 海量 UV 去重计数 |
二、短信登录 ------ Redis 替代 Session 实现分布式会话
2.1 Session 共享问题
在分布式/集群环境下,每个 Tomcat 都有独立的 Session,用户两次请求可能落到不同的 Tomcat 实例,导致 Session 丢失。
【早期方案】Session 拷贝
Tomcat-A (Session A) ←------------→ Tomcat-B (Session A') 优点:对业务透明 缺点:①每台服务器存全量数据,内存压力大 ②同步存在延迟
【最佳实践】Redis 集中存储
Tomcat-A ──→ Redis(集中存储)←── Tomcat-B
优点:①数据共享 ②高性能 ③可持久化
实现:用 Token 代替 JSessionId,Redis 存储用户信息
2.2 核心实现
设计思路:
-
验证码存入 Redis,key =
login:code:{phone},TTL = 2 分钟 -
登录成功后生成随机 Token,用户信息存入 Redis Hash,key =
login:token:{token},TTL = 30 分钟 -
前端每次请求在 Header 中携带
authorization字段传递 Token
关键代码(UserServiceImpl.login):
java
// 1.校验验证码(从Redis获取,而非Session)
String cacheCode = stringRedisTemplate.opsForValue()
.get(LOGIN_CODE_KEY + phone);
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 2.生成Token,用户信息存Redis Hash
String token = UUID.randomUUID().toString();
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, ...);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
2.3 双拦截器实现登录刷新
为什么需要两个拦截器?
初始方案在一个拦截器中同时做认证和刷新 Token,但拦截器只拦截需要登录的路径。如果用户访问不需要登录的路径,Token 就不会被刷新,可能导致 Token 过期。
优化方案:
请求到达
│
▼
RefreshTokenInterceptor (order=0,拦截所有路径)
│
├─ Header有token ─→ 从Redis获取用户信息
│ │
│ ├─ 存在 → 保存到ThreadLocal + 刷新token有效期 → 放行
│ └─ 不存在 → 直接放行
│
└─ Header无token → 直接放行
│
▼
LoginInterceptor (order=1,拦截需要登录的路径)
│
├─ ThreadLocal有用户 → 放行
└─ ThreadLocal无用户 → 返回 401
RefreshTokenInterceptor 核心代码:
java
public boolean preHandle(HttpServletRequest request, ...) {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) return true; // 没token也放行
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(LOGIN_USER_KEY + token);
if (userMap.isEmpty()) return true;
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
// 刷新有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true; // 始终放行
}
2.4 为什么用 Hash 而不是 String 存用户信息?
| 方案 | 优点 | 缺点 |
|---|---|---|
| String (JSON) | 简单,一次性读写 | 修改单个字段需要整体反序列化再序列化 |
| Hash | 每个字段独立存储,可单独操作 | 多占少量内存(每个field的key) |
本项目中用户信息不太复杂且很少单独修改字段,两种方案都可行,使用 Hash 方便未来扩展。
面试追问: 为什么验证码用 String 而用户信息用 Hash?
验证码只需要一个值且 2 分钟过期,String 最简单高效。用户信息有多个字段(id, nickName, icon 等),使用 Hash 可以按需读取单个字段,也更符合"对象"的语义。
三、商户查询缓存 ------ 缓存穿透/雪崩/击穿三连击
这是面试中必问的经典问题,三个概念经常被混淆,必须搞清楚它们的区别和对应解决方案。
3.1 缓存空对象解决缓存穿透
缓存穿透: 请求的数据在缓存和数据库中都不存在,缓存永远不生效,请求直接打到数据库。
解决方案: 即使数据库查不到,也将空值写入缓存(TTL 短一些,如 2 分钟)。
java
// CacheClient.queryWithPassThrough 核心逻辑
public <R, ID> R queryWithPassThrough(...) {
String json = stringRedisTemplate.opsForValue().get(key);
// 1.缓存命中
if (StrUtil.isNotBlank(json)) return JSONUtil.toBean(json, type);
// 2.命中空值(缓存穿透标记)
if (json != null) return null;
// 3.查数据库
R r = dbFallback.apply(id);
// 4.数据库也不存在 → 写空值到缓存,防止穿透
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5.写入缓存
this.set(key, r, time, unit);
return r;
}
StrUtil.isNotBlank vs json != null 的判断逻辑:
缓存中的值 isNotBlank json != null
==========================================================
"{\"name\":\"xx\"}" true(有数据) true
"" false(空字符串) true ← 这就是"穿透标记"
null false false
面试追问: 缓存空对象的缺点是什么?
-
额外内存消耗(空值也占一个 key)
-
短期数据不一致(缓存了空值后,数据库新增了这条数据,但缓存还没过期)
其他防穿透手段: 布隆过滤器、接口参数校验、用户鉴权、热点参数限流。
3.2 缓存雪崩
缓存雪崩: 同一时段大量缓存 key 同时失效,或者 Redis 服务宕机,导致请求全部打到数据库。
解决方案:
| 方案 | 说明 |
|---|---|
| TTL 添加随机值 | 避免大量 key 在同一时刻过期 |
| Redis 集群 | 主从 + 哨兵,保证高可用 |
| 多级缓存 | Nginx 缓存 + Redis + 本地缓存 |
| 限流降级 | Spring Cloud Gateway / Sentinel 保底 |
面试技巧: 缓存雪崩和缓存击穿都要回答"集群 + 多级缓存 + 限流",但雪崩侧重的是"大批量同时失效",击穿侧重的是"单个热点 key"。
3.3 互斥锁和逻辑过期解决缓存击穿
缓存击穿: 一个被高并发访问的热点 key 突然过期,无数请求在瞬间打到数据库。
方案一:互斥锁
java
// CacheClient.queryWithMutex 核心逻辑
public <R, ID> R queryWithMutex(...) {
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) return JSONUtil.toBean(json, type);
if (json != null) return null;
String lockKey = lockKeyPrefix + id;
R r = null;
try {
boolean isLock = tryLock(lockKey); // SETNX
if (!isLock) {
Thread.sleep(50); // 没抢到锁 → 休眠
return queryWithMutex(...); // 递归重试
}
// 抢到锁 → Double Check
r = dbFallback.apply(id);
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, ...);
} else {
this.set(key, r, time, unit);
}
} finally {
unlock(lockKey);
}
return r;
}
时间线 →
线程1:查询缓存(miss) → SETNX获取互斥锁(成功✓) → 查DB → 写缓存 → 释放锁
线程2:查询缓存(miss) → SETNX获取互斥锁(失败✗) → 休眠50ms → 重试 → 缓存命中✓
线程3:查询缓存(miss) → SETNX获取互斥锁(失败✗) → 休眠50ms → 重试 → 缓存命中✓
说明:只有第一个拿到锁的线程去查DB重建缓存,其他线程阻塞等待后直接读缓存即可。
方案二:逻辑过期
核心思路:不设置 Redis 的 TTL,而是把过期时间存在 value 里。当缓存"逻辑过期"时,返回旧数据,异步开启新线程去重建缓存。
java
// CacheClient.queryWithLogicalExpire
public <R, ID> R queryWithLogicalExpire(...) {
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) return null; // 没有预热过,直接返回
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return r;
}
// 已过期 → 尝试获取锁
String lockKey = lockKeyPrefix + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 抢到锁 → 开独立线程去重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R newR = dbFallback.apply(id);
this.setWithLogicalExpire(key, newR, time, unit);
} finally {
unlock(lockKey);
}
});
}
// 没抢到锁的线程 → 直接返回旧数据(容忍短暂的不一致)
return r;
}
时间线 →
线程1:查询缓存(命中但逻辑过期) → SETNX获取锁(成功✓) → 开异步线程B去重建 → 自己返回旧数据
线程2:查询缓存(命中但逻辑过期) → SETNX获取锁(失败✗) → 直接返回旧数据(不阻塞!)
线程B:查数据库 → 写入新缓存 → 释放锁 → 后续所有线程拿到新数据
说明:没有拿到锁的线程不等待,直接返回旧数据,保证高可用;重建完成后自动切换到新数据。
两种方案对比:
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强一致 | 最终一致(重建完成前返回脏数据) |
| 性能 | 串行执行,性能较低 | 不阻塞,性能高 |
| 实现复杂度 | 简单 | 复杂(需线程池、需预热) |
| 死锁风险 | 有(需设过期时间) | 有(同样需锁过期) |
| 适用场景 | 一致性要求高 | 可用性要求高 |
面试追问: 逻辑过期方案中,如果重建缓存的线程挂了怎么办?
需要加互斥锁超时时间(SETNX 时设置 10s 过期),确保锁最终会自动释放。
3.4 缓存与数据库双写一致性
更新策略:先操作数据库,再删除缓存 (Cache Aside Pattern)
java
@Transactional
public Result update(Shop shop) {
updateById(shop); // 1.更新数据库
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId()); // 2.删除缓存
return Result.ok();
}
为什么要删除缓存而不是更新缓存?
-
更新缓存:如果中间没有查询请求,连续 100 次更新就写了 100 次缓存,99 次是无用功
-
删除缓存:等下次查询时再加载最新数据,减少无用的写操作
为什么先操作数据库再删除缓存?
错误做法 ------ 先删缓存再更新数据库:
时间线 →
线程1:删除缓存 → → 更新DB(新值)
线程2: → 查缓存(miss) → 读DB(旧值) → 写缓存(旧值)
❌ 结果:DB是新值,缓存是旧值 → 不一致!
正确做法 ------ 先更新数据库再删除缓存:
时间线 →
线程1:更新DB(新值) → 刚准备删缓存
线程2: 查缓存 → 命中旧值(概率极低,DB写远慢于缓存读)
线程1: → 删除缓存
✅ 结果:短暂不一致(或根本不会发生),下次查询自动从DB加载新数据
结论: 先更新数据库再删除缓存,出现不一致的概率远低于先删除缓存再更新数据库。
3.5 封装 CacheClient 工具类
本项目将所有缓存操作封装到 CacheClient 中,提供了 5 个核心方法:
| 方法 | 功能 |
|---|---|
set |
普通写缓存 + TTL |
setWithLogicalExpire |
逻辑过期写缓存(不设 TTL) |
queryWithPassThrough |
缓存空对象防穿透 |
queryWithMutex |
互斥锁防击穿 |
queryWithLogicalExpire |
逻辑过期防击穿 |
通过函数式接口 Function<ID, R> dbFallback 实现回调,调用方只需传入 this::getById 方法引用即可,非常优雅。
四、优惠券秒杀 ------ 从单机锁到分布式锁的演进
4.1 全局唯一 ID 生成器
为什么不用数据库自增 ID?
-
ID 规律性太强,容易暴露业务数据(一天卖多少单)
-
分库分表时自增 ID 会冲突
雪花算法变体 ------ Redis 实现:
ID 结构(64位):
┌─────────┬──────────────────────┬──────────────────────────┐
│ 符号位 │ 时间戳 (31bit) │ 序列号 (32bit) │
│ 1bit │ 可用 69 年 │ 每秒可生成 2^32 个 │
└─────────┴──────────────────────┴──────────────────────────┘
java
// RedisIdWorker.nextId
public long nextId(String keyPrefix) {
// 1.时间戳(从自定义起始时间算起)
LocalDateTime now = LocalDateTime.now();
long timestamp = now.toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
// 2.自增序列号(按天分组,key = icr:{prefix}:{yyyy:MM:dd})
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3.位运算拼接:时间戳左移32位 | 序列号
return timestamp << COUNT_BITS | count;
}
面试技巧: 这个方案解决了两个问题 ------ ①全局唯一性(时间戳 + Redis 自增);②按天分组 key,方便按天统计,且 key 自动过期清理。
4.2 库存超卖问题 → 乐观锁
问题: 多线程并发查询库存 > 0,然后一起去扣减,导致超卖。
// 错误的裸扣库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // 没有锁保护!
.eq("voucher_id", voucherId).update();
CAS 乐观锁方案: 扣减时带上 where 条件
// 方案1:严格CAS → 失败率高,并发体验差
.eq("voucher_id", voucherId).eq("stock", voucher.getStock())
// 方案2:只要库存>0就能扣 → 最终方案 ✓
.eq("voucher_id", voucherId).gt("stock", 0)
面试追问: 乐观锁和悲观锁的区别?什么时候用哪个?
-
悲观锁 (synchronized/Lock): 假设冲突一定会发生,先加锁再操作。适合写多读少、冲突概率高的场景。
-
乐观锁 (CAS/版本号): 假设冲突不会发生,提交时检查版本。适合读多写少、冲突概率低的场景。秒杀场景中,使用乐观锁 +
stock > 0条件更合适,因为不需要让线程阻塞等待。
4.3 一人一单 → synchronized 到分布式锁
单机 synchronized 的问题演进:
java
// 阶段1:方法级 synchronized → 锁粒度太粗
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) { ... }
// 阶段2:缩小锁粒度 → intern() 保证同 userId 用同一把锁
synchronized (userId.toString().intern()) { ... }
// 问题:事务提交在 unlock 之后,可能导致其他线程读到未提交的数据
// 阶段3:事务和锁的包裹问题
// 解决方案:在 seckillVoucher 方法中获取锁,调用代理对象的 createVoucherOrder
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId); // 通过代理走事务
}
集群环境下 synchronized 失效原因:
┌─ Tomcat-A (JVM-1) ──────────────────────────┐
│ 线程1 ── synchronized(userId) ──→ 锁对象-A │
└──────────────────────────────────────────────┘
║ 不是同一个锁对象,互不感知!
┌─ Tomcat-B (JVM-2) ──────────────────────────┐
│ 线程2 ── synchronized(userId) ──→ 锁对象-B │
└──────────────────────────────────────────────┘
结论:集群环境下 JVM 级别的 synchronized 锁完全失效!
4.4 自定义 Redis 分布式锁
核心:SETNX + EX + 线程标识 + Lua 原子释放
java
// SimpleRedisLock ------ 加锁(SET key value NX EX timeout)
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
-- unlock.lua ------ 释放锁(拿锁、比锁、删锁 原子执行)
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
return redis.call('DEL', KEYS[1])
end
return 0
// SimpleRedisLock ------ 释放锁(调用Lua脚本保证原子性)
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
为什么释放锁要用 Lua 脚本?
时间线 →
线程1:GET lock → 返回 uuid-1(确认是"自己的锁")
↓ (正要执行DEL,此时锁到达TTL自动过期!)
Redis:锁过期自动删除
线程2: → SETNX lock uuid-2(成功✓)
线程1: → DEL lock
❌ 结果:线程1把线程2刚获取的锁删除了!这就是「误删」问题。
根本原因:GET 判断归属 + DEL 删除 不是原子操作,中间可以被其他命令插入。
✓ 解决方案:用 Lua 脚本把「判断归属 + 删除」打包成一个原子操作,在 Redis 内部一次执行完。
自定义分布式锁的局限性:
-
不可重入 ------ 同一个线程不能多次获取同一把锁
-
不可重试 ------ 尝试一次失败就直接返回
-
超时释放 ------ 业务执行时间超过锁过期时间,锁自动释放
-
主从一致性问题 ------ 主节点宕机,从节点提升但锁数据未同步
五、Redisson ------ 企业级分布式锁的完整方案
5.1 Redisson 解决了什么问题
| 自定义锁的痛点 | Redisson 的解决方案 |
|---|---|
| 不可重入 | Hash 结构 + 计数器实现可重入 |
| 不可重试 | 信号量 + 发布订阅实现等待唤醒 |
| 超时释放 | 看门狗 (Watch Dog) 自动续期 |
| 主从一致性 | MultiLock 联锁机制 |
5.2 可重入锁原理 ------ Hash 结构
java
Key: "lock:order:123"
Value (Hash):
field: "uuid-threadId" → value: 2 (重入次数)
-- Redisson 可重入加锁的 Lua 脚本核心逻辑
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1); -- 锁不存在,加锁 count=1
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; -- null = 加锁成功
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 是自己的锁,count+1
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; -- null = 重入成功
end;
return redis.call('pttl', KEYS[1]); -- 不是自己的锁,返回剩余TTL
5.3 看门狗 (WatchDog) 自动续期
核心原理:
lock() 不传 leaseTime → 默认超时 30s → 启动看门狗
┌──────────────────────────────────────┐
│ │
▼ │
每 10s 触发 renewExpirationAsync() │
│ │
├─ 续期成功 → 锁重置为 30s ──────────────┘
└─ 续期失败 → 进程可能已挂 → 30s后锁自动释放
java
// Redisson lock() 内部逻辑
private void renewExpiration() {
// 每 internalLockLeaseTime/3 毫秒执行一次续期
Timeout task = commandExecutor.getConnectionManager()
.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) {
// 续期到 30s
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (res) renewExpiration(); // 递归调用,持续续期
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
面试重点: 看门狗只在
lock()不带参数时生效。如果使用lock(10, TimeUnit.SECONDS)手动指定了 leaseTime,则不会启动看门狗,到时间自动释放。
5.4 MultiLock 解决主从一致性问题
问题: 主从结构中,锁写入主节点后,在同步到从节点之前主节点宕机,从节点提升但丢失了锁信息。
Redisson MultiLock 方案:
不去使用主从,而是多个独立的 Redis 节点(地位平等)
加锁逻辑:对每个节点依次加锁,全部成功才算加锁成功
释放逻辑:对每个节点依次释放
伪代码:
for (RedisNode node : nodes) {
lock.tryLock(node);
}
// 只有所有节点都加锁成功,才返回成功
本项目中的 RedissonConfig 配置了 3 个 Redis 实例(6379、6380、6381),正是为 MultiLock 做准备。
5.5 项目中的 Redisson 使用
java
// VoucherOrderServiceImpl 中使用 Redisson 实现一人一单
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = redisLock.tryLock();
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
redisLock.unlock();
}
六、秒杀优化 ------ Lua + Redis Stream 异步秒杀
6.1 为什么要优化
原流程是一个线程串行执行 6 个步骤(查优惠券 → 判时间 → 判库存 → 查订单 → 扣库存 → 创建订单),每个步骤都涉及数据库操作,单线程执行很慢。
优化思路:将耗时短的判断逻辑前置到 Redis 中,通过 Lua 脚本原子执行,判断通过后直接返回成功,异步慢慢处理下单。
6.2 Lua 脚本实现原子性资格判断
Lua
-- seckill.lua ------ 原子性完成:判库存 + 判重复 + 扣库存 + 发消息
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 1.判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 2.判断用户是否下过单(Set集合 SISMEMBER)
if (redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 重复下单
end
-- 3.扣减库存
redis.call('incrby', stockKey, -1)
-- 4.标记用户已下单
redis.call('sadd', orderKey, userId)
-- 5.发送消息到Stream队列
redis.call('xadd', 'stream.orders', '*',
'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0 -- 成功
Lua 脚本的优势:
-
原子性 ------ Redis 单线程执行 Lua 脚本,中间不会插入其他命令
-
减少网络开销 ------ 6 条 Redis 命令合并成一次网络请求
-
复用性 ------ 脚本存在 Redis 中,多个客户端可以调用
6.3 Redis Stream 作为消息队列
为什么选择 Stream 而不是 List 或 PubSub?
| 特性 | List (BRPOP) | PubSub | Stream (Consumer Group) |
|---|---|---|---|
| 消息回溯 | ❌ | ❌ | ✅ |
| 多消费者 | ❌ (争抢) | ✅ (广播) | ✅ (消费者组) |
| 消息确认 | ❌ | ❌ | ✅ (XACK) |
| 消息持久化 | ✅ | ❌ | ✅ |
| 阻塞读取 | ✅ | ✅ | ✅ |
项目中的消费者实现:
java
// VoucherOrderHandler ------ 使用 Stream Consumer Group
while (true) {
try {
// 1.读取消息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream()
.read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
if (list == null || list.isEmpty()) continue;
// 2.解析并创建订单
MapRecord<String, Object, Object> record = list.get(0);
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
record.getValue(), new VoucherOrder(), true);
handleVoucherOrder(voucherOrder);
// 3.确认消息 XACK stream.orders g1 recordId
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
} catch (Exception e) {
// 4.异常时处理 Pending List 中的消息
handlePendingList();
}
}
Pending List 兜底机制:
-
消费者拿到消息后如果处理失败,消息会进入 Pending List
-
handlePendingList()中读取 Pending List(ReadOffset.from("0"))进行重试 -
处理成功才 ACK,保证消息至少消费一次 (At-Least-Once)
6.4 整体秒杀架构
用户请求秒杀
│
▼
Controller.seckillVoucher()
│
▼
Lua脚本原子执行(判库存 + 判重复 + 扣库存 + XADD)
│
├─ return 0 ──→ 返回订单ID给用户(异步处理中)
│ │
│ ▼
│ Redis Stream 消息队列
│ │
│ ▼
│ 后台线程 VoucherOrderHandler (XREADGROUP 消费)
│ │
│ ├─ 消费成功 → handleVoucherOrder() → 创建数据库订单 → XACK确认
│ └─ 消费异常 → handlePendingList() → 重试处理
│
├─ return 1 ──→ 返回错误:库存不足
└─ return 2 ──→ 返回错误:不能重复下单
与阻塞队列对比:
-
JVM 阻塞队列:有内存限制、无法持久化、服务重启消息丢失
-
Redis Stream:不受 JVM 内存限制、持久化保证、消费者组 + ACK 保证可靠消费
七、达人探店 ------ SortedSet 实现点赞排行榜
7.1 为什么用 SortedSet 而不是 Set?
需求: 点赞的人需要展示最早的 TOP5,需要排序功能。
| 结构 | 唯一性 | 排序 | 结论 |
|---|---|---|---|
| Set | ✅ | ❌ | 不能满足 |
| List | ❌ (可重复) | ✅ | 不能满足 |
| SortedSet | ✅ | ✅ (按 score 排序) | ✅ 完美 |
7.2 实现点赞/取消点赞
java
// BlogServiceImpl.likeBlog ------ 使用 SortedSet 实现点赞
public Result likeBlog(Long id) {
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 未点赞 → 点赞:DB+1 + ZADD key timestamp userId
boolean isSuccess = update()
.setSql("liked = liked + 1").eq("id", id).update();
if (isSuccess) {
stringRedisTemplate.opsForZSet()
.add(key, userId.toString(), System.currentTimeMillis());
}
} else {
// 已点赞 → 取消:DB-1 + ZREM key userId
boolean isSuccess = update()
.setSql("liked = liked - 1").eq("id", id).update();
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
7.3 查询点赞 TOP5
java
// queryBlogLikes ------ ZRANGE 取前5名
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
// 按 SortedSet 中的顺序查询用户
List<UserDTO> userDTOS = userService.query()
.in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
八、好友关注 ------ Set 交集与 Feed 流推送
8.1 共同关注 ------ Set 交集
实现原理: 每个用户维护一个 Set,存其关注的用户 ID。求交集就是共同关注。
用户A关注的集合 —— follows:1001 → {1002, 1003, 1004}
用户B关注的集合 —— follows:1002 → {1003, 1004, 1005}
SINTER follows:1001 follows:1002
│
▼
共同关注 → {1003, 1004}
java
public Result followCommons(Long followUserId) {
Long userId = UserHolder.getUser().getId();
// 求交集
Set<String> intersection = stringRedisTemplate.opsForSet()
.intersect("follows:" + userId, "follows:" + followUserId);
// 查询用户详情
List<Long> ids = intersection.stream()
.map(Long::valueOf).collect(Collectors.toList());
List<UserDTO> users = userService.listByIds(ids)...;
return Result.ok(users);
}
8.2 Feed 流 ------ 推模式 + SortedSet
Feed 流的三种实现模式:
【三种模式对比】
拉模式(读扩散)------ 用户发布 → 存自己发件箱;粉丝上线后 → 逐个拉取关注者发件箱 → 合并排序展示 优点:节约存储 缺点:读取延迟大
推模式(写扩散)------ 用户发布 → 直接推到所有粉丝收件箱;粉丝上线 → 直接读自己收件箱 优点:读取快 缺点:大V粉丝多,存储压力大 ← 本项目使用纯推模式
推拉结合 ------ 普通用户用推模式;大V用拉模式(活跃粉丝推,非活跃粉丝拉)
本项目使用纯推模式: 发布笔记时写入所有粉丝的收件箱(SortedSet),粉丝读取时只需查自己的收件箱即可。
滚动分页(SortedSet + ZREVRANGEBYSCORE):
Feed 流的特殊性:数据不断新增,不能用传统的 page + size 分页。
T1 时刻:内容列表 [10,9,8,7,6] → 取第一页 (size=2) → 返回 [10,9] → 记录 lastId=9
T2 时刻:新内容 11 插入 → 内容列表 [11,10,9,8,7,6]
T3 时刻:取第二页 → 从 lastId=9 继续读 → 返回 [8,7]
✅ 没有重复、没有遗漏!完美解决 Feed 流滚动分页问题。
// 滚动分页查询收件箱
Set<ZSetOperations.TypedTuple<String>> typedTuples =
stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offSet, 2);
// 解析出 minTime 和 offset 用于下一次请求
九、附近商户 ------ GeoHash 实现 LBS 功能
9.1 GEO 数据导入
java
// 按 shop 类型分组,存入 GEO 集合
@Test
void loadShopData() {
List<Shop> list = shopService.list();
Map<Long, List<Shop>> map = list.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
String key = SHOP_GEO_KEY + typeId; // shop:geo:1 , shop:geo:2 ...
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for (Shop shop : entry.getValue()) {
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}
9.2 附近商户搜索
java
// GEOSEARCH key BYLONLAT x y BYRADIUS 5000 WITHDISTANCE
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
stringRedisTemplate.opsForGeo().search(
SHOP_GEO_KEY + typeId,
GeoReference.fromCoordinate(x, y), // 圆心坐标
new Distance(5000), // 半径 5000m
GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance() // 返回距离
.limit(end) // 分页
);
GEO 底层原理: Redis GEO 使用 GeoHash 算法,将经纬度编码为一个字符串,通过 ZSet 的 score 排序实现范围查询。同一个区域的坐标有共同的 GeoHash 前缀。
十、用户签到 ------ BitMap 统计连续签到
10.1 签到实现
java
// 每天签到 ------ SETBIT key offset 1
public Result sign() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix; // sign:1001:202501
int dayOfMonth = now.getDayOfMonth();
// 第1天对应 offset=0,第31天对应 offset=30
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
为什么用 BitMap?
| 方案 | 数据量 | 存储占用 |
|---|---|---|
| 传统 MySQL | 1000万用户 × 年均10次签到 = 1亿条/年 | 约 2GB+ |
| BitMap | 每人每月 31 个 bit | 1000万 × 4字节 = 40MB / 月 |
存储空间缩小了 50 倍以上!
10.2 连续签到天数计算
java
// 核心算法:BITFIELD 获取本月签到数据 → 位运算逐个统计连续1的个数
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
);
Long num = result.get(0);
int count = 0;
while (true) {
if ((num & 1) == 0) break; // 从最后一位开始判断
else count++;
num >>>= 1; // 右移1位
}
return Result.ok(count);
算法解析(假设签到状态 10110,代表签到第1,3,4天):
num = 10110
& 1 = 0 → 最后一天没签到 → break → count=0 ✓
num >>>= 1 → 1011
& 1 = 1 → count=1 → 101
& 1 = 1 → count=2 → 10
& 1 = 0 → break → count=2 ✓(连续签到了第3和第4天)
十一、UV 统计 ------ HyperLogLog 海量去重计数
11.1 使用场景
-
UV (Unique Visitor): 独立访客量,同一个用户一天内多次访问只算一次
-
PV (Page View): 页面访问量,每次打开页面都算一次
挑战: 如果每一个 UV 都存到 Redis Set 中,1亿用户需要约 1GB 内存。
HyperLogLog 的神奇之处: 单个 HLL 永远不超过 16KB ,却可以统计 2^64 个元素,误差率 < 0.81%。
11.2 核心实现
// 记录每个用户的 UV —— 每个小时一个 key
stringRedisTemplate.opsForHyperLogLog()
.add("uv:" + hourSuffix, userId.toString());
// 统计当天总 UV —— 合并 24 个 HLL
String[] hourKeys = new String[24];
for (int i = 0; i < 24; i++) {
hourKeys[i] = "uv:" + dateSuffix + ":" + i;
}
Long totalUV = stringRedisTemplate.opsForHyperLogLog()
.union("uv:total:" + dateSuffix, hourKeys);
十二、面试高频考点汇总
12.1 缓存穿透、缓存击穿、缓存雪崩
| 问题 | 现象 | 核心解决方案 |
|---|---|---|
| 缓存穿透 | 查一个不存在的数据 → 缓存和DB都没有 → 请求直击DB | ①缓存空值 ②布隆过滤器 ③参数校验 |
| 缓存击穿 | 热点 key 过期 → 大量请求同时查DB重建缓存 | ①互斥锁 ②逻辑过期(异步重建) |
| 缓存雪崩 | 大量 key 同时过期 或 Redis 宕机 → 请求全打DB | ①TTL加随机值 ②Redis集群 ③多级缓存 ④限流降级 |
面试回答模板: "这三个问题都是缓存和数据库之间的数据不一致或缓存失效导致的。穿透是数据根本不存在,击穿是单个热点 key 过期,雪崩是批量 key 同时过期或服务宕机。解决方案分别是缓存空值/布隆过滤器、互斥锁/逻辑过期、TTL 随机化/集群/多级缓存/限流。"
12.2 Redis 分布式锁 vs ZooKeeper 分布式锁
| 维度 | Redis | ZooKeeper |
|---|---|---|
| 原理 | SETNX | 临时顺序节点 + Watch |
| 性能 | 高(内存操作) | 较低(磁盘 + 网络开销) |
| 可靠性 | 需要额外机制(Redlock/MultiLock) | CP 强一致性,天然可靠 |
| 实现复杂度 | 简单(但有坑) | 复杂 |
| 适用场景 | 高并发、性能敏感 | 强一致性要求 |
Redlock 算法: Redis 作者提出的分布式锁算法,在 N 个独立的 Redis 节点上分别获取锁,当成功获取超过 N/2+1 个节点时,才算获取锁成功。本项目中的 MultiLock 就是 Redlock 的 Redisson 实现。
12.3 缓存一致性方案
四种更新策略:
| 策略 | 描述 |
|---|---|
| Cache Aside | 开发者手动控制,更新DB后删除缓存 ← 本项目使用 |
| Read/Write Through | 缓存层代理DB读写,业务无感知 |
| Write Behind | 只写缓存,异步批量写DB,性能最高但可能丢数据 |
为什么是删除缓存而不是更新缓存?
-
连续更新 100 次写 100 次缓存 vs 只删 1 次等下次读时重建
-
删除是幂等的,更新不是
12.4 Redis 为什么快?
-
基于内存 ------ 所有操作都是内存级别,不是磁盘 IO
-
单线程模型 ------ 避免了上下文切换、锁竞争(6.0 后网络 IO 多线程,命令执行仍单线程)
-
IO 多路复用 ------ epoll 模型处理高并发连接
-
高效的数据结构 ------ SDS、ziplist、skiplist 等经过精心优化
-
RESP 协议 ------ 简单高效的序列化协议
12.5 Redis 数据结构底层实现
| Redis 类型 | 底层数据结构 |
|---|---|
| String | SDS (简单动态字符串) |
| List | 3.2 前:LinkedList/ZipList;3.2 后:QuickList |
| Hash | ZipList / HashTable |
| Set | IntSet / HashTable |
| ZSet | ZipList / SkipList + HashTable |
| Stream | RadixTree (基数树) |
12.6 缓存预热怎么做?
本项目中的实现:在项目启动时,将热门商铺数据提前加载到 Redis 中,设置逻辑过期时间。
// 单元测试中预热
@Test
void testSaveShop() {
shopService.saveShop2Redis(1L, 20L); // 商铺ID=1,逻辑过期20秒
}
12.7 Redis 过期策略和内存淘汰策略
过期策略(删除过期 key):
-
惰性删除: 访问 key 时检查是否过期
-
定期删除: 每隔一段时间随机抽查一批 key 删除
内存淘汰策略(内存满时怎么处理):
-
noeviction:不淘汰,写操作报错(默认) -
allkeys-lru:所有 key 中 LRU 淘汰(推荐) -
volatile-lru:有过期时间的 key 中 LRU 淘汰 -
allkeys-lfu:所有 key 中 LFU 淘汰 -
volatile-ttl:按 TTL 淘汰
12.8 单机 Redis 撑不住怎么办?
| 方案 | 适用场景 |
|---|---|
| 主从复制 (Replication) | 读写分离,增加读并发 |
| 哨兵 (Sentinel) | 高可用,自动故障转移 |
| 分片集群 (Cluster) | 海量数据,水平扩展,支持 16384 个槽位 |
| Codis / Proxy 方案 | 大厂自研中间件 |
十三、项目中的 Redis 数据结构使用总结
| 数据结构 | 项目场景 | 关键命令 |
|---|---|---|
| String | 验证码存储、商户缓存、全局ID自增、分布式锁、库存 | SET/GET/INCR/SETNX |
| Hash | 用户信息缓存(Token→User) | HSET/HGET/HGETALL |
| Set | 一人一单判断、关注列表、共同关注 | SADD/SISMEMBER/SINTER |
| SortedSet | 点赞排行榜、Feed流收件箱 | ZADD/ZRANGE/ZSCORE/ZREVRANGEBYSCORE |
| List | (可做简单消息队列,项目未使用) | LPUSH/BRPOP |
| GEO | 附近商户搜索 | GEOADD/GEOSEARCH |
| BitMap | 用户签到 | SETBIT/BITFIELD |
| HyperLogLog | UV 统计 | PFADD/PFMERGE/PFCOUNT |
| Stream | 秒杀异步下单消息队列 | XADD/XREADGROUP/XACK |
| Lua | 秒杀资格判断、分布式锁原子释放 | EVAL/EVALSHA |
结语
通过黑马点评项目,我们完整实践了 Redis 在真实业务中的方方面面:从最基础的 String/Hash 做缓存、到 SortedSet 做排行榜、到 GEO 做附近的人、到 BitMap 做签到、到 Stream 做消息队列、再到 Redisson 分布式锁和 Lua 原子脚本。
面试中 Redis 相关的考察,核心就是三个方向:缓存三大问题 (穿透/击穿/雪崩)、分布式锁 (SETNX→Redisson→Redlock)、数据结构应用场景(什么场景用什么结构)。