Redis黑马点评 实战复盘与面试高频考点详解

Redis 实战复盘与面试高频考点详解(基于黑马课程的基础和实战篇展开、不涉及高级和原理篇)

基于黑马点评项目的 Redis 全场景实战复盘,涵盖短信登录、商户缓存、秒杀优化、分布式锁、消息队列、达人探店、好友关注、附近商户、签到统计等核心模块,同时包含后端面试中 Redis 高频考点的深度解析。

项目地址:HeiMaDianPing | 技术栈:SpringBoot 2.5.7 + MyBatis-Plus + Redis 6.2 + Redisson + Lua


目录

  1. [开篇:项目架构与 Redis 全景](#开篇:项目架构与 Redis 全景)

  2. [短信登录 ------ Redis 替代 Session 实现分布式会话](#短信登录 —— Redis 替代 Session 实现分布式会话)

  3. [商户查询缓存 ------ 缓存穿透/雪崩/击穿三连击](#商户查询缓存 —— 缓存穿透/雪崩/击穿三连击)

  4. [优惠券秒杀 ------ 从单机锁到分布式锁的演进](#优惠券秒杀 —— 从单机锁到分布式锁的演进)

  5. [Redisson ------ 企业级分布式锁的完整方案](#Redisson —— 企业级分布式锁的完整方案)

  6. [秒杀优化 ------ Lua + Redis Stream 异步秒杀](#秒杀优化 —— Lua + Redis Stream 异步秒杀)

  7. [达人探店 ------ SortedSet 实现点赞排行榜](#达人探店 —— SortedSet 实现点赞排行榜)

  8. [好友关注 ------ Set 交集与 Feed 流推送](#好友关注 —— Set 交集与 Feed 流推送)

  9. [附近商户 ------ GeoHash 实现 LBS 功能](#附近商户 —— GeoHash 实现 LBS 功能)

  10. [用户签到 ------ BitMap 统计连续签到](#用户签到 —— BitMap 统计连续签到)

  11. [UV 统计 ------ HyperLogLog 海量去重计数](#UV 统计 —— HyperLogLog 海量去重计数)

  12. 面试高频考点汇总

  13. [项目中的 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 内部一次执行完。

自定义分布式锁的局限性:

  1. 不可重入 ------ 同一个线程不能多次获取同一把锁

  2. 不可重试 ------ 尝试一次失败就直接返回

  3. 超时释放 ------ 业务执行时间超过锁过期时间,锁自动释放

  4. 主从一致性问题 ------ 主节点宕机,从节点提升但锁数据未同步


五、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 脚本的优势:

  1. 原子性 ------ Redis 单线程执行 Lua 脚本,中间不会插入其他命令

  2. 减少网络开销 ------ 6 条 Redis 命令合并成一次网络请求

  3. 复用性 ------ 脚本存在 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 为什么快?

  1. 基于内存 ------ 所有操作都是内存级别,不是磁盘 IO

  2. 单线程模型 ------ 避免了上下文切换、锁竞争(6.0 后网络 IO 多线程,命令执行仍单线程)

  3. IO 多路复用 ------ epoll 模型处理高并发连接

  4. 高效的数据结构 ------ SDS、ziplist、skiplist 等经过精心优化

  5. 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)、数据结构应用场景(什么场景用什么结构)。

相关推荐
それども1 小时前
怎么理解TCP的状态
java·网络·网络协议·tcp/ip·dubbo
英俊潇洒美少年1 小时前
Vue2 $set 深度解析 + 批量更新全套优化方案(原理+实战+踩坑+面试)
面试·职场和发展·wps
YOU OU1 小时前
案例综合练习-博客系统
java·开发语言
瑞雪兆丰年兮1 小时前
[从0开始学Java|第十八、十九天]API(常见API&对象克隆&正则表达式)
java·开发语言
KobeSacre1 小时前
JVM G1 垃圾回收器
java·开发语言·jvm
林的快手1 小时前
MySQL
数据库·oracle
身如柳絮随风扬1 小时前
MySQL 存储引擎深度解析:InnoDB vs MyISAM vs Memory,行锁实现与索引奥秘
数据库·mysql
KaMeidebaby2 小时前
卡梅德生物技术快报|基因测序技术在 46,XY 性发育障碍变异筛查中的流程与数据分析
服务器·前端·数据库·人工智能·算法·数据挖掘·数据分析
摇滚侠2 小时前
浏览器调试工具 检查元素 谷歌模拟器 控制台 断点调试
java·html