一、缓存性能问题
1、缓存规模过大
问题:
在高并发应用场景中,我们可以用 Redis 缓存缓解数据库的读压力。但是,对于类似淘宝、京东之类的大型电商,商品数量众多,如果我们把用户访问的每个商品都放到 Redis 缓存,那么随着时间的推移,缓存的规模会越来越大,内存的成本也会不断增加。
解决方案:
实际上,只有少量的数据才是在一段时间内被高频访问的,其它的数据只是偶尔被访问。所以可以在写缓存时设置过期时间,并在读缓存时更新过期时间,从而不断淘汰冷数据,保证缓存的规模不会一直增长。
2、缓存击穿
问题:
当一个记录的不在 Redis 缓存,而同时有大量请求查询这条记录时,这些请求会同时绕过缓存打到数据库上,给数据库造成巨大压力。比如,当一个大主播在直播间推荐一款产品,但是这个产品在此前是冷数据,近期都没有人访问过所以不在缓存中,那么大量用户同时点击时,在缓存重建成功之前,所有的查询都会到达数据库。
解决方案:
1、预热:对可能出现的热点数据,在大量请求到来之前提前加载到缓存中,并设置较长的有效期。
2、加锁:当查询缓存不存在时,只允许其中一个线程查询数据库并重建缓存,其它线程阻塞直到缓存重建完成为止。示例如下:
java
public Product get(Long productId){
Product product = null;
String productCacheKey = "PRODUCT_" + productId;
product = getProductFromCache(productCacheKey);
if (product != null){
return product;
}
// 通过分布式锁解决热点缓存并发重建问题
RLock hotCreateCacheLock = redisson.getLock("LOCK_PRODUCT_HOT_CACHE_CREATE_" + productId);
hotCreateCacheLock.lock();
try {
product = getProductFromCache(productCacheKey);
if (product != null){
return product;
}
product = getProductFromDB(productId);
if (product != null){
jedis.set(productCacheKey, product, 30s);
}
} catch (Exception e){
...
} finally {
hotCreateCacheLock.unlock();
}
}
值得注意的是,上述代码我们使用了 DCL 技术(双重检查锁),在获取锁之前检查一次数据是否已经在缓存中,从而减少了锁的竞争。
3、缓存穿透
问题:
当前端查询一个不存在的记录时,后端从 Redis 缓存中查询结果为空,会认为缓存没有命中,进而从数据库查询。但是数据库中实际上不存在这个记录,所以不会进一步写入缓存。在高并发场景下,大量的这种查询会全部打到数据库上,给数据库造成巨大压力。
解决方案:
1、在前端或后端 Controller 层进行业务参数校验,提前拦截非法的查询参数,比如商品 id 为负数。
2、当数据库查询记录不存在时,向缓存插入一条空记录(比如将值设为 {}),并设置一个较短的过期时间。
3、在防火墙、WAF 等安全产品上配置策略防护,防止面向缓存穿透的 DOS 攻击。
4、缓存雪崩
问题:
当由于大量 key 到期,或由于 Redis 发生故障,从而导致大量 key 的缓存在同一时间集体失效,导致大量请求无法命中缓存而直接打到数据库,给数据库造成巨大压力。
解决方案:
1、对 key 的缓存有效期进行小幅度的随机偏移,避免大量 key 同时到期。
2、使用 Redis 集群实现 Redis 服务的高可用
5、超高并发的Redis性能问题
问题:
当超高并发压力产生时(比如超级热点新闻被几亿人同时访问),即使作为缓存的 Redis 集群也未必能扛得住。
解决方案:
1、使用应用节点本地缓存。如果说 Redis 缓存的是热点数据,那么本地缓存的是热点中的热点。由于本地缓存没有网络IO的损耗而更快,且应用节点的横向扩展性一般会好于存储层,所以本地缓存可以抗住更高的并发。不过,本地缓存的容量一般比较小,所以需要精准判断哪些数据进入本地缓存以及其过期时间。
2、对于本地缓存,仍然存在多个节点本地缓存的一致性问题。此时,可以使用 Redis PUB/SUB 或 MQ 实现更新事件的广播,当一个节点更新热点数据的内容时,将更新事件广播给其它节点,其它节点收到后,更新本地缓存。不过,这些逻辑会加重业务代码的复杂性。
3、鉴于本地缓存的容量限制以及对业务耦合性的增加,对于高并发业务,一些公司会专门构建热点缓存系统,通过发布订阅机制与应用节点交互,来管理和同步应用节点的本地缓存。
4、在前端使用 CDN 技术。将静态内容存储在各 CDN 节点中,前端对该静态资源的访问会被引流到用户近缘的 CDN 节点,在提升访问速度的同时,请求也不会再打到后端。不过这种方法在资源发生改变时,需要重新回源,导致更新延迟。
6、分布式锁高度竞争问题
问题:
在高并发商品促销秒杀场景下,大量线程竞争一把 Redis 分布式锁进行减库存操作,使得业务容易出现超卖问题,并同时导致 Redis 的单个节点压力过大。
解决方案:
使用分段锁的思想。比如,某商品A 有 1000个 秒杀名额,那么可以将原来的一个分布式锁 LOCK_PRODUCT_A 拆分为10个锁:LOCK_PRODUCT_A_10_1、LOCK_PRODUCT_A_10_2、...、LOCK_PRODUCT_A_10_10,每个锁控制 100个 秒杀名额,当用户请求到达后端时,随机分配一个锁并使线程参与竞争,这样每把锁的竞争压力就变为原来的 1/10。
二、缓存/数据库双写不一致问题
1、先更新缓存,再更新数据库
如果缓存更新成功,但是数据库更新失败。那么显然,用户后续查询得到的数据,是缓存的脏数据。
2、先更新数据库,后更新缓存
在不考虑并发的场景,如果更新缓存失败,那么应用程序的事务框架可以对数据库的本地事务进行回滚,从而保证数据库中的数据和缓存的一致性。
但是在并发场景下,还是有可能因为修改异常而导致缓存和数据库的数据不一致。我们假设有3个线程 A、B、C,其中,A、B 都执行"更新库存"的操作,C 执行"查询库存"的操作,它们在某次并发执行时,实际的执行次序如下:
时间 T:A线程,更新数据库:产品库存为 10
时间 T + 1:B线程,更新数据库:产品库存为 10 - 1 = 9
时间 T + 2:B线程,更新缓存:产品库存为 9
时间 T + 3:A线程,更新缓存:产品库存为 10
时间 T + 4:C线程,查询缓存:产品库存为 10
此时,数据库中库存为 9,缓存中库存为 10,数据不一致。
3、先更新数据库,后删除缓存
数据变更后删除缓存,并在查询缓存不存在时更新缓存。这可以解决刚才说的并发更新库存造成的缓存与数据库不一致问题。
时间 T:A线程,更新数据库:产品库存为 10
时间 T + 1:B线程,更新数据库:产品库存为 10 - 1 = 9
时间 T + 2:B线程,删除缓存
时间 T + 3:A线程,删除缓存
时间 T + 4:C线程,查询缓存,由于数据不存在,所以查询数据库:产品库存为9,更新到缓存中。
此时,数据库中库存为 9,缓存中库存为 9,数据不一致。
但是,我们考虑这种情况:
时间 T:C线程,查询库存,由于缓存中不存在,所以从数据库查,数据库中记录的库存为 10
时间 T + 1:A线程,更新数据库:产品库存为 10 - 1 = 9
时间 T + 2:A线程,删除缓存
时间 T + 3:C线程,更新缓存:产品库存为 10
此时,数据库中库存为 9,缓存中库存为 10,数据不一致。
解决方案:
我们可以为更新库存、查询库存这两个操作加分布式锁,于是上述的 A、B 、C 线程就会串行化执行,从而保证缓存和数据库的一致。
进一步优化:
1、使用双重检查锁(DCL)来减少锁的争用
2、使用 Redisson 提供的读写锁,对于两个只读操作可以避免互斥,从而减少锁的争用。
三、结语
对于一个使用了缓存的应用系统,如果要强调它缓存数据的准确性,那么无论是代码的复杂度,还是系统的性能,都会有负面的影响。这一点需要根据业务实际需求权衡考虑。
比如,我们在更新数据时,先更新数据库,再删缓存,在查询数据时再重建缓存,在并发度不是非常高的情况下,一般也不会出现缓存和数据库不一致的问题,即使出现了,也只是少量可以接受的偏差。但如果我们要保证绝对正确,就需要加分布式锁,那么性能就会下降,代码就会变得复杂。