1、实际场景

有一个短链接跳转的sass系统,假设客户很多,在短链接进行跳转时肯定会用到redis这就涉及到了缓存穿透 缓存雪崩 缓存击穿等问题

1.1、有关短链接的解释如下

短链接平台是一种在线服务,它将长的网址(URL)转换为更短的链接。这些短链接更便于分享,特别是在字符数有限的环境中,比如社交媒体平台。使用短链接平台不仅可以节省空间,还可以提供额外的功能,如点击统计、自定义短链接、以及访问控制等。
短链接的典型格式是由平台的域名加上一串字符组成,这串字符代表了原始的长链接。当用户点击这个短链接时,短链接平台会自动将用户重定向到原始的长链接所指向的网页。这个过程对用户来说是透明的,他们可能根本意识不到链接已经被转换和重定向了。
短链接平台的一些常见应用包括但不限于:

  • 在社交媒体上分享链接,尤其是在Twitter这样字符限制的平台上。
  • 在印刷材料上,如名片或广告,使用短链接可以节省空间,同时也便于记忆。
  • 跟踪营销活动的效果,通过不同的短链接来跟踪点击率和用户行为。
  • 为了美观或保密目的,隐藏原始链接的复杂性或长度。

1.2、有关缓存击穿、雪崩与穿透

Redis作为一种常用的内存数据存储系统,经常被用作缓存来提高数据访问的速度和效率。然而,在使用Redis作为缓存时,可能会遇到几种典型的问题,包括缓存穿透、缓存雪崩和缓存击穿。这些问题都可能对系统的性能和稳定性产生负面影响。下面分别解释这三种情况:

1.2.1、缓存穿透

缓存穿透是指查询一个数据库中不存在的数据。由于缓存是不命中的,每次查询都会穿过缓存去查询数据库。如果有大量这样的查询,数据库就会受到很大的压力。缓存穿透的一个典型场景是恶意用户故意查询不存在的数据,使得数据库压力增大。

解决办法:

  • 布隆过滤器: 使用布隆过滤器预先过滤掉可能不存在的数据请求。
  • 缓存空对象: 当数据库中查询不到数据时,仍然将这个查询的结果(空对象)缓存起来,并设置一个较短的过期时间。

1.2.2、缓存雪崩

缓存雪崩是指在某一个时间点,由于大量的缓存同时过期,导致原本应该命中缓存的请求都落到了数据库上,从而引发数据库瞬时压力过大。这种情况可能由缓存服务器重启或者大量缓存设置了相同的过期时间引起。

解决办法:

  • 设置不同的过期时间: 使缓存的过期时间分散开,避免同时大量缓存过期。
  • 缓存预热: 在缓存到期前,提前对缓存进行更新。
    使用高可用的缓存架构: 比如使用Redis集群来提高缓存系统的稳定性。

1.2.3、缓存击穿

缓存击穿与缓存穿透不同,它是指缓存中有这个数据,但是已经过期,此时有大量并发请求这个数据。因为缓存没有命中,所有的请求都去数据库查询数据,然后重新设置到缓存中,这可能会对数据库造成巨大压力。

解决办法:

  • 设置热点数据永不过期: 对于一些经常被大量访问的热点数据,可以设置其永不过期。
  • 互斥锁: 当缓存失效时,不是所有的请求都去数据库加载数据,而是使用某种机制(如分布式锁)保证只有一个请求去数据库加载数据,其他请求等待。

2、如何解决

1
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) throws IOException {

// 获取完整短链接
final String fullShortUrl = request.getServerName() + "/" + shortUri;

// 从缓存中获取短链接所对应的完整链接
String originalLink = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl));

// 缓存存在的话直接进行短链接跳转
if (Opp.ofStr(originalLink).isPresent()) {

((HttpServletResponse) response).sendRedirect(originalLink);
return;
}

// 从布隆过滤器中查看有没有这个短链接
final boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);

if (!contains){
// 不存在的话直接跳转自定义404界面
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}

// 如果存在于布隆过滤器,可能存在误判。所以缓存中存放了一个数据库中短链接是否为null的
final String link = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_NULL_SHORT_LINK_KEY, fullShortUrl));

// 如果为null的话还是直接跳转自定义404界面
if (Opp.ofStr(link).isPresent()) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}

// 添加分布式锁
final RLock lock = redissonClient.getLock(String.format(RedisKeyConstant.LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));

lock.lock();
try {
// 加锁之后再去缓存中判断一次
originalLink = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl));
if (Opp.ofStr(originalLink).isPresent()) {

// 如果存在直接跳转
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}
// 如果不存在的话,去数据库中查询
final ShortLinkGotoDO shortLinkGotoDO = One.of(ShortLinkGotoDO::getFullShortUrl).eq(fullShortUrl).query();
if (shortLinkGotoDO == null) {
// 如果数据库不存在的话存放一个临时的空值,防止缓存穿透
stringRedisTemplate.opsForValue().set(String.format(RedisKeyConstant.GOTO_NULL_SHORT_LINK_KEY, fullShortUrl), "-",30 , TimeUnit.SECONDS);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// 从数据库获取完整短链接
final ShortLinkDO shortLinkDO = One.of(ShortLinkDO::getGid).eq(shortLinkGotoDO.getGid()).condition(w -> w.eq(ShortLinkDO::getFullShortUrl, fullShortUrl).eq(ShortLinkDO::getEnableStatus, 0)).query();
if (Opp.of(shortLinkDO).isPresent()) {
// 判断短链接是否已经过期
if (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date())) {
// 证明已经过期
stringRedisTemplate.opsForValue().set(String.format(RedisKeyConstant.GOTO_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// 如果数据库存在的话设置缓存到redis,并进行跳转
stringRedisTemplate.opsForValue()
.set(
String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY,
fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()));
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
}
}finally {
lock.unlock();
}
}

对应的时序图

3、代码逐行解析

3.1、获取短链接

1
final String fullShortUrl = request.getServerName() + "/" + shortUri;

这行代码拼接了服务器的名称和短链接的唯一标识符shortUri来构成完整的短链接fullShortUrl。

3.2、从缓存中获取原始链接

1
String originalLink = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl));

这行代码尝试从Redis缓存中获取短链接所对应的原始链接。这是为了减少对数据库的访问,提高响应速度。

3.3、缓存存在检查

1
2
3
4
if (Opp.ofStr(originalLink).isPresent()) {
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}

如果缓存中存在原始链接,则直接重定向到原始链接,这一步骤帮助防止缓存击穿。

3.4、布隆过滤器检查

1
final boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);

这行代码使用布隆过滤器检查短链接是否存在,这是为了防止缓存穿透,即防止恶意用户通过不断请求不存在的短链接来使得服务直接访问数据库。

3.5、布隆过滤器不存在处理

1
2
3
4
if (!contains) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}

如果布隆过滤器判断短链接不存在,则直接重定向到404页面,避免了对数据库的无效访问。

3.6、缓存为空值检查

1
final String link = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_NULL_SHORT_LINK_KEY, fullShortUrl));

这行代码检查是否缓存了一个表示数据库中没有对应记录的空值,这是为了处理布隆过滤器的误判。

3.7、重定向到404页面

1
2
3
4
if (Opp.ofStr(link).isPresent()) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}

如果缓存中存储了一个表示短链接在数据库中不存在的值,则直接重定向到404页面。

3.8、添加分布式锁

1
final RLock lock = redissonClient.getLock(String.format(RedisKeyConstant.LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));

这行代码为当前操作的短链接添加了一个分布式锁,这是为了防止缓存击穿,即在缓存失效的瞬间,大量的并发请求直接打到数据库。

3.9、锁定和再次检查缓存

1
2
3
4
5
6
7
lock.lock();
try {
originalLink = stringRedisTemplate.opsForValue().get(String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY, fullShortUrl));
if (Opp.ofStr(originalLink).isPresent()) {
((HttpServletResponse) response).sendRedirect(originalLink);
return;
}

这部分代码首先对短链接加锁,然后再次检查缓存,如果这时候缓存中存在原始链接,则直接重定向,这可以处理高并发下的缓存击穿问题。

3.10、数据库查询和缓存更新

1
final ShortLinkGotoDO shortLinkGotoDO = One.of(ShortLinkGotoDO::getFullShortUrl).eq(fullShortUrl).query();

如果缓存中没有找到原始链接,代码会继续从数据库查询。这里使用了某种ORM框架的查询语法来获取短链接对应的数据对象。

1
2
3
4
5
if (shortLinkGotoDO == null) {
stringRedisTemplate.opsForValue().set(String.format(RedisKeyConstant.GOTO_NULL_SHORT_LINK_KEY, fullShortUrl), "-",30 , TimeUnit.SECONDS);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}

如果数据库中也不存在该短链接,则在缓存中设置一个短期的空值并重定向到404页面,这是为了防止缓存穿透。

1
final ShortLinkDO shortLinkDO = One.of(ShortLinkDO::getGid).eq(shortLinkGotoDO.getGid()).condition(w -> w.eq(ShortLinkDO::getFullShortUrl, fullShortUrl).eq(ShortLinkDO::getEnableStatus, 0)).query();

这行代码进一步查询获取短链接的详细信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (Opp.of(shortLinkDO).isPresent()) {
if (shortLinkDO.getValidDate() != null && shortLinkDO.getValidDate().before(new Date())) {
stringRedisTemplate.opsForValue().set(String.format(RedisKeyConstant.GOTO_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
stringRedisTemplate.opsForValue()
.set(
String.format(RedisKeyConstant.GOTO_SHORT_LINK_KEY,
fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()));
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
}

如果查询到短链接且未过期,则更新缓存并重定向到原始链接,这样可以防止后续的缓存穿透和击穿问题。

3.11、释放分布式锁

1
2
3
} finally {
lock.unlock();
}

最后释放分布式锁,以允许其他线程处理其他短链接。