登录与缓存优化
# 登录与缓存优化
笔记参考:https://cyborg2077.github.io/2022/10/22/RedisPractice/ (opens new window)
# 1. 你是怎么实现登录的?
我这里使用的是短信验证,不过没有具体实现,因为实现需要使用第三方的接口调用短信服务的 API 接口。
实现登录由三部分构成:发送短信验证码、验证登录信息、保持登录态和刷新登录态。
# 发送短信验证码过程
- 校验手机号是否符合规则,如果不符合则返回错误信息。这里校验用的是正则表达式,我单独建立了一个存放正则表达式的类,因为还会有其他的正则表达式(例如邮箱、账户、密码、验证码的正则),如果不符合规则直接报错。
- 如果手机号符合规则,生成六位随机数字验证码,这里使用了 Hutool 工具类的
RandomUtil工具类,随机生成 6 位随机数字。 - 将验证码保存在 Redis 中,使用 Redis 的 String 字符串数据结构来保存。保存的验证码因为要有唯一性,我这里用手机号作为 key,不过加了一个前缀来标识,因为后面还会有很多的其他 key 值,设置一个前缀能够很好地区分其它存储在缓存中的 Key 值。并且单独建了一个工具类,存了这些静态的名称,方便管理和调用,比如这个登录验证码就叫做
login:code:,看 Redis 数据的时候比较方便也好理解。最后设置了 2 分钟的过期时间,达到这个时间自动删除。 - 发送短信验证码。
- 返回成功信息。
# 验证登录信息过程
- 校验手机号是否符合规则,如果不符合则返回错误信息。
- 从 Redis 中获取该手机号对应的验证码,并判断验证码是否与用户输入的一致,如果不一致则返回错误信息。
- 如果验证码一致,根据手机号查询用户是否存在。
- 如果用户不存在,创建用户。
- 将用户信息存储到 Redis 中。
- 随机生成一个 token,作为用户的登录令牌,将用户信息以 hash 的形式保存在 Redis 中,key 为
LOGIN_USER_KEY + token。这里的 token 用了 UUID 随机生成全局唯一标识,避免 token 重复,还设置了一个有效期,不然就会一直挂在那里。 - 返回 token。
# 登录态和刷新登录态实现
用户在前端输入用户名和密码,发送了 POST 请求到后端,后端进行验证,如果验证通过,则在后端创建一个 Token,并将该 Token 保存在 Redis 中,同时将 Token 和用户不重要的信息返回给前端。例如用户 id、用户名称、头像这种不需要保密的信息返回,所以我这里用了一个 UserDTO,把 User 实体类的一些需要的属性提炼了出来进行使用。
我这里使用了两个拦截器:
- 一个拦截所有请求:用于检查用户是否已登录,例如存入 Redis 的 token 已经过期了,就属于退出登录了。
- 一个用于刷新 Token:如果用户访问页面超过了 30 分钟,也是有操作的,如果不刷新 token,就会导致突然下线,这样用户体验不好。
# 2. 你是怎么实现商户缓存?为什么要这样做?
# Redis 缓存商户信息
商户信息缓存的实现一般是将商户信息存储到 Redis 中,下次查询时先从缓存中获取,如果缓存中存在数据则直接返回给用户,否则从数据库中查询并将结果存入缓存。
- 从 Redis 中获取 id 对应的店铺信息,如果缓存命中则直接返回。
- 如果没有命中 Redis 缓存,则从数据库中获取 id 对应的店铺信息。
- 如果数据库中也没有该店铺信息,则返回查询失败的信息。
- 如果在数据库中找到了该店铺信息,将其转换为 JSON 字符串,并将其存入 Redis 中,以便下次查询时使用。
- 最后将找到的店铺信息返回给客户端。

这个方法的优点是可以减轻数据库的查询负担,因为如果 Redis 缓存中存在该店铺信息,就可以直接返回,不用查询数据库。缺点在于需要手动管理 Redis 缓存的更新,确保与数据库中的信息一致。
# 缓存更新问题
缓存更新我了解到的大概有以下三种方案:
- 内存淘汰:Redis 自动进行,当 Redis 内存达到我们设定的 maxmemory 时,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)。
- 超时剔除:当我们给 Redis 设置了过期时间 TTL 之后,Redis 会将超时的数据进行删除,方便我们继续使用缓存。
- 主动更新:我们可以手动调用方法把缓存删除掉,通常用于解决缓存和数据库不一致问题。
业务场景:
- 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存(因为这个很长一段时间都不需要更新)。
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存。
我们这里选择用主动更新的双写方案解决店铺信息缓存和数据库不一致问题,也就是当缓存调用者在更新完数据库之后再去更新缓存。我这里是直接删除缓存,因为更新缓存需要进行一些无效的写操作,直接删除缓存快一些,还不容易出错。
下面我就是先操作数据库,再删除缓存,尽量的避免线程安全问题。
这种情况下的线程问题,只有一种情况:当线程 1 在查询缓存的时候,缓存 TTL 刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短,但是就在这么短的时间内,线程 2 进来了,更新数据库,删除缓存。但是线程 1 虽然查询完了数据(更新前的旧数据),但是还没来得及写入缓存,所以线程 2 的更新数据库与删除缓存,并没有影响到线程 1 的查询旧数据、写入缓存,造成线程安全问题。但是这种情况很少出现。
# 缓存穿透问题
缓存穿透:客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远都不会生效(只有数据库查到了,才会让 Redis 缓存,但现在的问题是查不到),会频繁地去访问数据库。
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗,可能造成短期的不一致
- 布隆过滤
- 优点:内存不占用,没有多余的 key
- 缺点:实现复杂,可能存在误判
缓存空对象思路分析:当我们客户端访问不存在的数据时,会先请求 Redis,但是此时 Redis 中也没有数据,就会直接访问数据库,但是数据库里也没有数据,那么这个数据就穿透了缓存,直击数据库。但是数据库能承载的并发不如 Redis 这么高,所以如果大量的请求同时都来访问这个不存在的数据,那么这些请求就会访问到数据库。简单的解决方案就是哪怕这个数据在数据库里不存在,我们也把这个数据存在 Redis 中去(这就是为啥说会有额外的内存消耗),这样下次用户过来访问这个不存在的数据时,Redis 缓存中也能找到这个数据,不用去查数据库。可能造成的短期不一致是指在空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的 TTL 还没过,所以当用户来查询的时候,查询到的还是空对象,等 TTL 过了之后,才能访问到正确的数据,不过这种情况很少见罢了。
布隆过滤思路分析:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去判断当前这个要查询的数据是否存在。如果布隆过滤器判断存在,则放行,这个请求会去访问 Redis,哪怕此时 Redis 中的数据过期了,但是数据库里一定会存在这个数据,从数据库中查询到数据之后,再将其放到 Redis 中。如果布隆过滤器判断这个数据不存在,则直接返回。这种思想的优点在于节约内存空间,但存在误判,误判的原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希冲突。
我这里使用的是缓存空对象的方式,实现的步骤如下:
- 先去 Redis 中查询目标数据是否已经缓存,如果能查询到就直接返回缓存的数据;
- 如果 Redis 中不存在目标数据,则去 MySQL 数据库中查询是否存在该数据;
- 如果数据库中也查询不到,则将一个空字符串作为响应内容存入 Redis,设置**短暂(一般 5 分钟以内)**的过期时间,并返回错误信息;
- 如果数据库中存在该数据,则将数据缓存到 Redis 中,并设置过期时间。
@Override
public Result queryById(Long id) {
// 先从Redis中查,这里的常量值是固定的前缀 + 店铺id
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 如果不为空(查询到了),则转为Shop类型直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 如果查询到的是空字符串,则说明是我们缓存的空数据
if (shopJson != null) {
return Result.fail("店铺不存在!!");
}
// 否则去数据库中查
Shop shop = getById(id);
// 查不到,则将空字符串写入Redis
if (shop == null) {
// 这里的常量值是2分钟
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!!");
}
// 查到了则转为json字符串
String jsonStr = JSONUtil.toJsonStr(shop);
// 并存入redis,设置TTL
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 最终把查询到的商户信息返回给前端
return Result.ok(shop);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
小总结:
- 缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力。
- 缓存穿透的解决方案有哪些?
- 布隆过滤器:通过布隆过滤器进行缓存前置过滤,过滤掉肯定不存在的 Key,减少对后端存储系统的无用查询。
- 数据预热:在系统启动时,将热点数据提前加载到缓存中,避免后续在访问静态数据时缓存穿透。
- 缓存空值:当缓存中不存在指定数据时,将一个空值或哨兵值写入缓存,可以缓解缓存穿透问题。
- 降级保护:当缓存数据不存在或缓存系统出错时,通过熔断等手段返回默认值或错误提示,避免数据来源被攻击造成故障。
- 黑白名单:加上一个限制机制,例如限制每个 IP 对同一目标数据的访问频率,如果某个 IP 访问频率异常高,就暂时将其加入黑名单,拒绝新的访问请求。
# 缓存雪崩问题及解决方案
缓存雪崩:在同一时间段,大量缓存的 key 同时失效,或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的 Key 的 TTL 添加随机值,让其在不同时间段分批失效。
- 缓存数据永不过期:对于一些不会经常改变的数据,可以设置为永不过期,这样可以将这些数据作为一种缓存数据的备选方案,避免缓存雪崩导致数据不可用。
- 利用 Redis 集群提高服务的可用性(使用一个或者多个哨兵 Sentinel 实例组成的系统,对 Redis 节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转移,保证系统的可用性)。
- 给缓存业务添加降级限流策略。
- 给业务添加多级缓存(浏览器访问静态资源时,优先读取浏览器本地缓存;访问非静态资源(ajax 查询数据)时,访问服务端;请求到达 Nginx 后,优先读取 Nginx 本地缓存;如果 Nginx 本地缓存未命中,则去直接查询 Redis(不经过 Tomcat);如果 Redis 查询未命中,则查询 Tomcat;请求进入 Tomcat 后,优先查询 JVM 进程缓存;如果 JVM 进程缓存未命中,则查询数据库)。
# 缓存击穿问题及解决思路
缓存击穿也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击。
举个不太恰当的例子:一件秒杀中的商品的 key 突然失效了,大家都在疯狂抢购,那么这个瞬间就会有无数的请求访问去直接抵达数据库,从而造成缓存击穿。
常见的解决方案有下面几种:
- 热点数据预热
- 布隆过滤器
- 互斥锁方案
- 逻辑过期方案
# 解决方案一:互斥锁
- 利用锁的互斥性,假设线程过来,只能一个人一个人的访问数据库,从而避免对数据库频繁访问产生过大压力。但这也会影响查询的性能,将查询的性能从并行变成了串行,我们可以采用
tryLock方法 + double check 来解决这个问题。 - 线程 1 在操作的时候,拿着锁把房门锁上了,那么线程 2、3、4 就不能都进来操作数据库,只有 1 操作完了,把房门打开了,此时缓存数据也重建好了,线程 2、3、4 直接从 Redis 中就可以查询到数据。

# 解决方案二:逻辑过期方案
- 方案分析:我们之所以会出现缓存击穿问题,主要原因在于我们对 key 设置了 TTL,如果我们不设置 TTL,那么就不会有缓存击穿问题,但是不设置 TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案。
- 我们之前是 TTL 设置在 Redis 的 value 中,注意:这个过期时间并不会直接作用于 Redis,而是我们后续通过逻辑去处理。假设线程 1 去查询缓存,然后从 value 中判断当前数据已经过期了,此时线程 1 去获得互斥锁,那么其他线程会进行阻塞。获得了锁的进程他会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成了逻辑之后,才会释放锁。而线程 1 直接进行返回,假设现在线程 3 过来访问,由于线程 2 拿着锁,所以线程 3 无法获得锁,线程 3 也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程 2 重建缓存数据之后,其他线程才能返回正确的数据。
- 这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据。

# 对比互斥锁与逻辑过期
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 没有额外的内存消耗;保证一致性;实现简单 | 线程需要等待,性能受影响;可能有死锁风险 |
| 逻辑过期 | 线程无需等待,性能较好 | 不保证一致性;有额外内存消耗;实现复杂 |
# 利用互斥锁解决缓存击穿问题
- 核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是,进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询。
- 如果获取到了锁的线程,则进行查询,将查询到的数据写入 Redis,再释放锁,返回数据。利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿。

核心思路就是利用 Redis 的 setnx 方法来表示获取锁,如果 Redis 没有这个 key,则插入成功,返回 1;如果已经存在这个 key,则插入失败,返回 0。在 StringRedisTemplate 中返回 true/false,我们可以根据返回值来判断是否有线程成功获取到了锁。
实现步骤:
- 先从 Redis 缓存中获取指定 id 的商店信息(以
CACHE_SHOP_KEY + id作为 Redis 的 key),如果命中缓存则直接返回缓存中的商店信息。 - 如果缓存未命中,则加锁(用分布式锁,以保证并发读取时的数据安全,这里使用的是 Redis 的
setnx命令,set if not exists。这个命令是原子性的,即同一时间只能有一个客户端能够成功地获取锁)。 - 再次从缓存中获取指定 id 的商店信息,如果此时缓存命中则表示其他线程已经读取缓存,当前线程等待一段时间后再次获取缓存(这里是等待 50 毫秒)。
- 如果缓存未命中,则从数据库中获取该商店的信息。
- 如果数据库中存在该商店信息,则将该信息序列化为 json 字符串,并将 json 字符串保存至 Redis 缓存中,并设置过期时间;如果数据库中不存在该商店信息,则在 Redis 缓存中写入空值,并设置空值的过期时间。
- 最后释放分布式锁。
- 返回查询到的商店信息。
总的来说,该方法的实现步骤可以概括为:缓存命中则直接返回;缓存未命中则加锁,再次从缓存中获取,如果仍未命中则从数据库中获取,并将结果写入缓存中;最后释放锁并返回查询结果。
# 利用逻辑过期解决缓存击穿问题
- 需求:根据 id 查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。
- 思路分析:当用户开始查询 Redis 时,判断是否命中
- 如果没有命中则直接返回空数据,不查询数据库
- 如果命中,则将 value 取出,判断 value 中的过期时间是否满足
- 如果没有过期,则直接返回 Redis 中的数据
- 如果过期,则开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁

封装数据:因为现在 Redis 中存储的数据的 value 需要带上过期时间,此时要么你去修改原来的实体类,要么新建一个类包含原有的数据和过期时间,这里选择新建一个类。
这里我们选择新建一个实体类,包含原有数据(用万能的 Object)和过期时间,这样对原有的代码没有侵入性:
@Data
public class RedisData<T> {
private LocalDateTime expireTime;
private T data;
}
2
3
4
5
这样就可以把商品信息存为 data,还有一个单独的 expireTime。
正式代码如下:
// 这里需要声明一个线程池,因为下面我们需要新建一个线程来完成重构缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Override
public Shop queryWithLogicalExpire(Long id) {
// 1. 从redis中查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 2. 如果未命中,则返回空
if (StrUtil.isBlank(json)) {
return null;
}
// 3. 命中,将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 3.1 将data转为Shop对象
JSONObject shopJson = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
// 3.2 获取过期时间
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断是否过期
if (LocalDateTime.now().isBefore(expireTime)) {
// 5. 未过期,直接返回商铺信息
return shop;
}
// 6. 过期,尝试获取互斥锁
boolean flag = tryLock(LOCK_SHOP_KEY + id);
// 7. 获取到了锁
if (flag) {
// 8. 开启独立线程
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, LOCK_SHOP_TTL);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(LOCK_SHOP_KEY + id);
}
});
// 9. 直接返回商铺信息
return shop;
}
// 10. 未获取到锁,直接返回商铺信息
return shop;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43