1.缓存收益
1.1加速读写
通过缓存加速读写速度
1.2降低后端负载
后端服务器通过前端缓存降低负载:业务端使用Redis降低后端MySQL负载等
2.缓存成本
1.数据不一致:缓存层和数据层有时间窗口不一致,和更新策略有关。
2.代码维护成本:多了一层缓存逻辑
3.运维成本:例如Redis Cluster
3.使用场景
3.1.降低后端负载:
- 对高消耗的SQL:join结果集/分组统计结果缓存
3.2.加速请求响应:
- 利用Redis/Memcache优化IO响应
3.3.大量写合并为批量写:
- 如计数器先Redis累加再批量写DB
4.缓存更新策略
1.LRU/LFU/FIFO算法剔除:例如maxmemory-policy
2.超时剔除:例如expire
3.主动跟新:开发控制声明周期
策略 | 一致性 | 维护成本 |
---|---|---|
LRU/LIRS |
最差 | 低 |
超时剔除 | 较差 | 低 |
主动更新 | 强 | 高 |
开发建议
- 低一致性:最大内存和淘汰策略
2.高一字型:超时剔除和主动更新结合,最大内存和淘汰策略保底。
5.缓存粒度控制
- 全部属性:
set user:{id} 'select * from user where id={id}'
- 部分重要属性:
set user:{id} 'select importantColumn1,.. importantColumnM from user where id={id}'
通用性。缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性。
空间占用。缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
全部是数据会造成内存的浪费。
全部数据可能每次传输产生的网络流量会比较大,耗时相对较大,在极端情况下会阻塞网络。
全部数据的序列化和反序列化的CPU开销更大。
代码维护。
全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据。
下表给出缓存全部数据和部分数据在通用性、空间占用、diamante维护上的对比,开发人员酌情选择。
数据类型 | 通用性 | 空间占用(内存空间+网络宽带) | 代码维护 |
---|---|---|---|
全部数据 | 高 | 大 | 简单 |
部分数据 | 低 | 小 | 较为复杂 |
缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,网络带宽的浪费,代码通用性较差等情况,需要综合数据通用性、空间占用比、代码维护性三点进行取舍。
6.缓存穿透
缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。
缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。
造成缓存穿透的基本原因有两个:
第一,自身业务代码或者数据出现问题
第二,一些恶意攻击、爬虫等造成大量空命中。
解决方式:
1.缓存空对象(设置过期时间、消息队列刷新null值改变的情况)
public Object getObjectInclNullById(Integer id) {
// 从缓存中获取数据
Object cacheValue = cache.get(id);
// 缓存为空
if (cacheValue != null) {
// 从数据库中获取
Object storageValue = storage.get(key);
// 缓存空对象
cache.set(key, storageValue);
// 如果存储数据为空,需要设置一个过期时间(300秒)
if (storageValue == null) {
// 必须设置过期时间,否则有被攻击的风险
cache.expire(key, 60 * 5);
}
return storageValue;
}
return cacheValue;
}
数据命中不高,数据频繁变化实时性高,代码维护简单,需要过多的缓存空间,会有数据不一致的问题.
2.布隆过滤器拦截
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class BloomFilterTest {
// 初始化一个能够容纳10000个元素且容错率为0.01布隆过滤器
private static final BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
/**
* 初始化布隆过滤器
*/
private static void initLegalIdsBloomFilter() {
// 初始化10000个合法Id并加入到过滤器中
for (int legalId = 0; legalId < 10000; legalId++) {
bloomFilter.put(legalId);
}
}
/**
* id是否合法有效,即是否在过滤器中
*
* @param id
* @return
*/
public static boolean validateIdInBloomFilter(Integer id) {
return bloomFilter.mightContain(id);
}
public static void main(String[] args) {
// 初始化过滤器
initLegalIdsBloomFilter();
// 误判个数
int errorNum=0;
// 验证从10000个非法id是否有效
for (int id = 10000; id < 20000; id++) {
if (validateIdInBloomFilter(id)){
// 误判数
errorNum++;
}
}
System.out.println("judge error num is : " + errorNum);
}
}
/**
* 防缓存穿透的:布隆过滤器
*
* @param id
* @return
*/
public Object getObjectByBloom(Integer id) {
// 判断是否为合法id
if (!bloomFilter.mightContain(id)) {
// 非法id,则不允许继续查库
return null;
} else {
// 从缓存中获取数据
Object cacheValue = cache.get(id);
// 缓存为空
if (cacheValue == null) {
// 从数据库中获取
Object storageValue = storage.get(id);
// 缓存空对象
cache.set(id, storageValue);
}
return cacheValue;
}
}
这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
7.缓存击穿
1、使用互斥锁排队
业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁,单机的话用普通的锁(synchronized、Lock)就够了。
public String getWithLock(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
// 通过key获取value
String value = redisService.get(key);
if (StringUtil.isEmpty(value)) {
// 分布式锁,详细可以参考https://blog.csdn.net/fanrenxiang/article/details/79803037
//封装的tryDistributedLock包括setnx和expire两个功能,在低版本的redis中不支持
try {
boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
if (locked) {
value = userService.getById(key);
redisService.set(key, value);
redisService.del(lockKey);
return value;
} else {
// 其它线程进来了没获取到锁便等待50ms后重试
Thread.sleep(50);
getWithLock(key, jedis, lockKey, uniqueId, expireTime);
}
} catch (Exception e) {
log.error("getWithLock exception=" + e);
return value;
} finally {
redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
}
}
return value;
}
2、接口限流与熔断、降级
重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
3、设置热点数据永远不过期。
9.缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
- 设置热点数据永远不过期。
8.无底洞优化
2010年,facebook的Memcache节点已经达到了3000个,承载着TB级别的缓存数据。但开发和运维人员发现一个问题,为了满足业务要求添加了大量新Memcache节点,但是发现性能不但没有好转反而下降了,当时将这种现象称为缓存的“无底洞”现象。
那么为什么会产生这种现象呢,通常来说添加节点使得Memcache集群性能应该更强了,但事实并非如此。键值数据库由于通常采用哈希函数将key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不同节点上获取,相对于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间。
下图展示了在分布式条件下,一次mget操作需要访问多个Redis节点,需要多次网络时间。
而下图由于所有键值都集中在一个节点上,所以一次批量操作只需要一次网络时间。
上面已经对批量操作的四中方案进行了介绍,最后通过下表来对四种方案的优缺点、网络IO次数进行一个总结。
方案 | 优点 | 缺点 | 网络IO |
---|---|---|---|
串行命令 | 1.编程简单。2.如果少量keys,性能可以满足要求 | 大量keys请求延迟严重 | O(keys) |
串行IO | 1.编程简单。2.少量节点,性能满足要求 | 大量node延迟严重 | O(nodes) |
并行IO | 利用并行特性,延迟取决于最慢的节点 | 1.编程复杂。2.由于多线程,问题定位可能较难 | O(max_slow(nodes)) |
hash_tag | 性能最高 | 1.业务维护成本较高。2.容易出现数据倾斜 | O(1) |
作者:linuxzw