1 缓存收益与成本
下图左侧为客户端直接调用存储层的架构,右侧为比较典型的缓存层+存储层架构。
【收益】
-
加速读写:因为缓存通常都是全内存的(例如Redis、Memcache),而存储层通常读写性能不够强悍(例如MySQL)
-
降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。
【成本】
-
数据不一致性:缓存层和存储层的数据存在不一致可能。
-
代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。
-
运维成本:以Redis Cluster为例,加入后无形中增加了运维成本。
2 缓存更新策略
缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更新,这样可以保证缓存空间在一个可控的范围。
【定时更新】
定时将底层数据库内的数据更新到缓存中,该方法比较简单,适合需要缓存的数据量不是很大,且数据变更不频繁的场景。
【读请求更新】
在用户有读请求时,先判断该请求数据的缓存是否存在,如果不存在则进行数据库查询,同步至缓存并设置过期时间,然后返回给用户,存在则直接返回给用户。
【写请求更新】
在用户有写请求时,先写数据库再同步更新缓存数据,这适合需要缓存与数据库数据强一致性的场景。
【策略对比】
策略 | 一致性 | 维护成本 | 适用场景 |
---|---|---|---|
定时更新 | 最差 | 低 | 数据量不大,且数据变更频率低 |
读请求更新 | 较差 | 较低 | 对数据一致性要求不高,允许短时间不一致 |
写请求更新 | 强 | 高 | 对数据一致性要求高 |
【使用建议】
高一致性业务:结合使用写请求更新与读请求更新,这样即使主动更新出了问题,也能定期修复同时删除脏数据。
【缓存更新方案】
以下都建立在不考虑缓存超时的场景下:
解决超时问题可以通过,合理设置超时时间与超时重试机制解决。重试可采用同步执行或者定时驱动等方法实现。
- 方案一:写入更新。缓存数据构建简单的场景,可以直接写入缓存
@Transactional(rollbackFor = Exception.class) public void upsert() throws InnerException { // 0 准备数据 // 1 更新数据 dataGateway.upsert1(data1); dataGateway.upsert2(data2); // 2 更新缓存,失败回滚 if (!cacheGateway.upsert(data3)) { throw new InnerException(null, InnerExceptionEnum.HANDLER_EXCEPTION, "upsert failed"); } }
- 方案二:延时双删。缓存数据构建耗时长,对更新操作性能要求高的场景可以使用
为什么要延迟:避免A事物执行删除后B事物读取到从库等情况导致缓存数据不准确
为什么要前置删除:避免后置异步缓存删除失败导致缓存数据有误,同时可基本保证延迟期间更新成最新缓存
延时时间要求:需要大于缓存重构时间
@Transactional(rollbackFor = Exception.class)
public void upsert() throws InnerException {
// 0 准备数据
// 1 删除缓存
if (!cacheGateway.delete(key)) {
throw new InnerException(null, InnerExceptionEnum.HANDLER_EXCEPTION, "delete failed");
}
// 2 更新数据
dataGateway.upsert1(data1);
dataGateway.upsert2(data2);
// 3 异步延迟再次删除缓存
if (!cacheGateway.asyncDelete(key)) {
throw new InnerException(null, InnerExceptionEnum.HANDLER_EXCEPTION, "delete failed");
}
}
3 缓存无底洞
【背景】
2010年,Facebook的Memcache节点已经达到了3000个,承载着TB级别的缓存数据。但开发和运维人员发现了一个问题,为了满足业务要求添加了大量新Memcache节点,但是发现批量操作性能反而下降了,当时将这种现象称为缓存的“无底洞”现象。
【原因】
无论Memcache还是Redis,都是采用哈希函数将key映射到各个节点上,使key的分布与业务无关,批量操作通常需要从不同节点执行,因此会涉及多次网络开销与命令执行。
【Redis Cluster分片】
Redis Cluster采用了哈希槽 (hash slot)的方式来分配。
-
有一个16384长度的槽的概念,槽是一个虚拟概念,并不是真正存在。
-
每个Master节点都会负责一部分的槽,映射到这些槽的key,会统一由这个Master节点提供服务,至于哪个Master节点负责哪个槽,这是可以由用户指定的,也可以在初始化的时候自动生成。
-
节点横向扩容时,只需要简单执行命令进行槽迁移即可。
redis-trib.rb reshard host:port --from <arg> --to <arg> --slots <arg> --yes --timeout <arg> --pipeline <arg> 参数说明: --host:port:必传参数,集群内任意节点地址,用来获取整个集群信息。 --from:制定源节点的id,如果有多个源节点,使用逗号分隔,如果是all源节点变为集群内所有主节点,在迁移过程中提示用户输入。 --to:需要迁移的目标节点的id,目标节点只能填写一个,在迁移过程中提示用户输入。 --slots:需要迁移槽的总数量,在迁移过程中提示用户输入。 --yes:当打印出reshard执行计划时,是否需要用户输入yes确认后再执行reshard。 --timeout:控制每次migrate操作的超时时间,默认为60000毫秒。 --pipeline:控制每次批量迁移键的数量,默认为10。
-
key属于 CRC16(key) & (16384-1),下面是jedis计算slot源码。JedisClusterCRC16类中
public static int getSlot(byte[] key) { int s = -1; int e = -1; boolean sFound = false; for (int i = 0; i < key.length; i++) { if (key[i] == '{' && !sFound) { s = i; sFound = true; } if (key[i] == '}' && sFound) { e = i; break; } } if (s > -1 && e > -1 && e != s + 1) { return getCRC16(key, s + 1, e) & (Constants.CLUSTER_SLOT_COUNT - 1); } return getCRC16(key) & (Constants.CLUSTER_SLOT_COUNT - 1); }
【解决方案】
-
串行IO:先将属于同一个节点的key进行归档,之后对每个节点执行mget操作,它的操作时间=node次网络时间+n次命令时间
-
并行IO:该方案是串行IO的多线程版本,网络次数虽然还是节点个数,但由于使用多线程网络时间变为O(1)
【Squirrel源码】
-
AbstractStoreClient.multiGet:这一层使用模版模式完成监控工作,raptor上报的内容都在这一层完成
@Override public <T> Map<StoreKey, T> multiGet(List<StoreKey> keys, final long timeout, final DeliverOption deliverOption) { checkNotNull(keys, "store key list is null"); if (keys.isEmpty()) { return Collections.emptyMap(); } final String category = keys.get(0).getCategory(); final StoreCategoryConfig categoryConfig = categoryConfigManager.findCacheKeyType(category); final KeyHolder keyHolder = new KeyHolder(categoryConfigManager, keys); final List<String> finalKeyList = keyHolder.getFinalKeys(); // 1 MonitorCommand为装饰器,内部完成了比如Transaction、ITracer上报 return new MonitorCommand(new Method(Method.Command.MULTI_READ, "multiGet").batchCount(keys.size()) , storeType, categoryConfig) { @Override public Object excute() throws Exception { // 2 具体完成multiget在这一步 Map<String, T> innerResult = doMultiGet(categoryConfig, finalKeyList, timeout, deliverOption, false); Map<StoreKey, T> result = keyHolder.convertKeys(innerResult); int hits = result.size(); int successRate = (int) (100.0F * ((float) hits / finalKeyList.size())); HitRateMonitor.getInstance().logHitRateOrThrows(multiHitRateEvent, successRate, deliverOption.getMinSuccessRate(), hits); return result; } }.run(); }
-
doMultiGet方法:使用适配器模式支持了ehcache(纯Java的分布式的进程内缓存框架)、memcache、redis三种缓存。下面是实现
- 查询核心逻辑在BinaryJedisClusterMultiKeyCommand.syncRun方法。完成了key->节点的映射
Map<byte[], T> syncRun(byte[][] keys) { // 1 超时时间判断:默认100ms long nanos = TimeUnit.MILLISECONDS.toNanos(multiCommandTimeout); // 2 获取key所属spot对应节点的连接池,节点维度循环调用 Map<JedisPool, List<byte[]>> directory = poolToKeys(command, keys); ArrayList<Future<Map<byte[], T>>> tasks = new ArrayList<Future<Map<byte[], T>>>(directory.size() + 1); List<HostAndPort> hosts = new ArrayList<HostAndPort>(directory.size() + 1); try { final Map<byte[], T> result = new HashMap<byte[], T>(capacity(keys.length)); // 3 准备任务阶段,完成tasks准备 for (final Map.Entry<JedisPool, List<byte[]>> entry : directory.entrySet()) { hosts.add(entry.getKey().getHostAndPort()); tasks.add(new FutureTask<Map<byte[], T>>(new Callable<Map<byte[], T>>() { @Override public Map<byte[], T> call() throws Exception { Jedis jedis = null; LatencyMonitor latencyMonitor = null; try { jedis = entry.getKey().getResource(); latencyMonitor = connectionHandler.newRequestMonitor(jedis.getHost()); List<byte[]> dirKeys = entry.getValue(); Pipeline pipeline = jedis.pipelined(); ArrayList<Response<T>> responses = new ArrayList<Response<T>>(dirKeys.size() + 1); for (byte[] key : dirKeys) { // 查询操作,使用pipeline技术循环单条查询 responses.add(execute(pipeline, key)); } pipeline.sync(); return getByteResponse(responses, dirKeys); } catch (JedisException e) { connectionHandler.handleException(e); if (latencyMonitor != null) { latencyMonitor.setStatus(e); } throw e; } finally { if (jedis != null) { try { jedis.close(); } catch (Throwable ignore) { } } if (latencyMonitor != null) { latencyMonitor.methodCompleted(); } } } })); } final long deadline = System.nanoTime() + nanos; final int size = tasks.size(); // 4 提交tasks任务,超时异常 for (Future<Map<byte[], T>> task : tasks) { this.multiCommandExecutor.execute((Runnable) task); nanos = deadline - System.nanoTime(); if (nanos <= 0L) { throw new JedisException("system load maybe higher, please check cpu load and gc status."); } } // 5 返回任务,支持两种策略(尽可能返回,全部返回) for (int i = 0; i < size; i++) { Future<Map<byte[], T>> f = tasks.get(i); if (!f.isDone()) { // 未完成:超时处理逻辑 if (nanos <= 0L) { if (this.resultOption == JedisClusterMultiKeyCommand.ResultOption.BEST_EFFORT) { for (int j = i + 1; j < size; j++) { Future<Map<byte[], T>> ft = tasks.get(j); if (ft.isDone()) { try { result.putAll(ft.get()); } catch (ExecutionException ee) { // throw data Exception if (ee.getCause() instanceof JedisDataException) { throw (JedisDataException)ee.getCause(); } } catch (Throwable ignore) { } } } return result; } else { throw new JedisConnectionException( "Multikey operation is timeout beyond " + multiCommandTimeout + " milliseconds."); } } // 未完成:未超时处理逻辑 if (this.resultOption == JedisClusterMultiKeyCommand.ResultOption.BEST_EFFORT) { try { result.putAll(f.get(nanos, TimeUnit.NANOSECONDS)); } catch (ExecutionException ee) { if (ee.getCause() instanceof JedisDataException) { throw (JedisDataException)ee.getCause(); } } catch (Throwable ignore) { } } else { try { result.putAll(f.get(nanos, TimeUnit.NANOSECONDS)); } catch (ExecutionException ee) { if (ee.getCause() instanceof JedisException) { throw (JedisException) ee.getCause(); } else { throw new JedisException(ee.getCause()); } } catch (TimeoutException toe) { if (connectionHandler.getExceptionServerMonitor() != null) { connectionHandler.getExceptionServerMonitor().logTimeoutServer(hosts.get(i).toString()); } throw new JedisConnectionException( "Multikey operation is timeout beyond " + multiCommandTimeout + " milliseconds."); } catch (Throwable e) { throw new JedisException(e); } } nanos = deadline - System.nanoTime(); } else { // 已完成 try { result.putAll(f.get()); } catch (ExecutionException ee) { // throw data Exception if (ee.getCause() instanceof JedisDataException) { throw (JedisDataException)ee.getCause(); } } catch (Throwable ignore) { } } } return result; } finally { for (Future<Map<byte[], T>> f : tasks) { if (!f.isDone() && !f.isCancelled()) { f.cancel(true); } } } }
- 获取slot对应连接池
public JedisPool getJedisPoolFromSlot(int slot, Protocol.Command command) { JedisPool connectionPool = null; // 写操作/强制查主库返回master节点连接池,否则主从节点随机分配 if (isWriteCommand(command)) { connectionPool = cache.getSlotPool(slot); } else { if (!forceMaster()) { connectionPool = getConnectionPool(slot); } if (connectionPool == null) { connectionPool = cache.getSlotPool(slot); } } return connectionPool; }
4 穿透、击穿、雪崩
4.1 缓存穿透
是指查询本不存在的数据,那么所有请求都会落在DB上,在高并发或者有人恶意攻击的场景下会对数据库造成很大的负荷甚至崩溃。
【方案:缓存空对象】
一般开发过程中不建议将null存入缓存,因为会占用更多内存空间(如果是攻击,问题更严重),同时使用不当会出现空指针。
做法:存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。
【方案:布隆过滤器拦截】
这种方法适用于数据命中不高的应用场景,可以过滤掉绝大多数无效请求。
4.2 缓存击穿
缓存击穿,是指一个key非常热点,大并发集中对这一个点进行访问。这个key缓存在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
【解决方案】
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:减少重建缓存的次数。
-
加锁:只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
-
缓存永不过期:不设置过期时间,也就不会出现热点key过期产生的击穿
4.3 缓存雪崩
由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
【解决方案】
-
保证缓存服务高可用:如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,Redis Cluster就实现了高可用。
-
熔断与限流:可以考虑通过对服务或者接口限流,或者熔断的方式,以保证整个系统的可用性。
-
降级预案:降级机制在高并发系统中是非常普遍也是非常必须的。
-
比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据。
-
Redis缓存不可用,降级tair缓存。
-
4.4 方案举例
public String query(String key) throws InnerException {
// 1 缓存穿透:布隆过滤器
if(!bloomFilter.isExist(key)){
return "非法访问";
}
// 核心逻辑:查询并返回数据
String rst = redisGateway.query(key);
if(rst != null){
return rst;
}
// 2 缓存击穿:分布式锁,直到获取成功
while(true){
if(redisGateway.lock(key)){
break;
}
Thread.sleep(50);
rst = redisGateway.query(key);
if(rst != null){
return rst;
}
}
rst = dbGateway.query(key);
redisGateway.set(rst);
return rst;
}
5 数据结构选择
五种基本数据结构 | 类型 | 适用场景 | 备注 |
---|---|---|---|
String | String | value 较小,模型简单的业务场景 | Redis的 String 是二进制安全的,没有任何限制(基于此强烈不建议将任何数据都丢到String中)。 |
Bitmap位图 | 底层string实现,最大内存为512M,支持约42亿数据统计
|
| |
HyperLogLog | 海量数据基数(去重后个数)统计,可以容忍一定的误差。
| 最多 12 KB 内存,就可以计算接近 2^64 个不同元素的基数,标准误差为 0.81% 。 | |
Hash | / | (1) 适合存储对象类型数据; |
|
List | / | (1) 顺序插入,获取最近数据。或者分页查询; |
|
Set | / | 集合,可对数据去重;
|
|
Zset | Zset | 排名/排行榜
|
|
GEO | 地理位置信息
|
|
6 扩展
6.1 布隆过滤器
布隆过滤器(Bloom Filter):是由Howard Bloom在1970年提出的一种比较巧妙的概率型数据结构,它可以告诉你某种东西一定不存在或者可能存在。
-
不存在:一定不存在
-
存在:可能不存在
优点:
-
布隆过滤器相对于Set、Map 等数据结构来说,它可以更高效地插入和查询
-
占用空间更少
缺点:
-
会出现误判。但是只要参数设置的合理,它的精确度也可以控制的相对精确。
-
无法删除。一个放入容器的元素删除可能会影响其他元素的判断。
基本使用:
-
定义预估容量+误判率。布隆过滤器会根据数据计算并分配一个合适大小的内存。
-
大于预估容量时误判率会提升。
-
-
add命令:添加单个元素到布隆过滤器中。
-
madd命令:添加多个元素到布隆过滤器中。
-
exists:判断某个元素是否在过滤器中,返回不存在或可能存在。
原理介绍:
-
数据结构:Redis中布隆过滤器的数据结构是一个很大的位数组。
-
哈希函数:多个无偏哈希函数(能把元素的哈希值算得比较平均,能让元素被哈希到位数组中的位置比较随机)。
-
具体实现:
-
计算参数:根据用户指定的容量和误判率计算出位数组大小、哈希函数个数并选择哈希函数。
-
插入数据:如下图,A、B、C就是三个这样的哈希函数,分别对“OneMoreStudy”和“万猫学社”这两个元素进行哈希,位数组的对应位置则被设置为1。
-
数据过滤:向布隆过滤器查询元素是否存在时,和添加元素一样,也会把哈希的几个位置算出来,对应位置只要有一个为0,那么说明元素不存在。如果这几个位置都为1,说明可能存在
-
出现误判原因:这些位置为1可能是因为其他元素的存在导致。
-
无法删除:如果删除某个元素的位置信息,可能会删除其他元素的信息。
-
-
Redis支持:之前的布隆过滤器可以使用Redis中的位图操作实现,直到Redis4.0版本提供了插件功能,Redis官方提供的布隆过滤器才正式登场。
Squirrel支持:RedisBloom使用文档
6.2 pipeline技术
【pipeline作用】
Redis客户端执行一条命令分为发送命令 -> 命令排队 -> 命令执行 -> 返回结果四步,称为Round Trip Time(RTT,往返时间)。Redis提供了批量操作命令(例如mget、mset等),有效地节约RTT。但大部分命令是不支持批量操作的,例如要执行n次hgetall命令,需要消耗n次RTT。那怎么解决这个问题呢?
Pipeline(流水线)机制能改善上面这类问题,减少网络io耗时,它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端
【对比批量命令】
可以使用Pipeline模拟出批量操作的效果,但是在使用时要注意它与原生批量命令的区别,具体包含以下几点:
-
原生批量命令是原子的,Pipeline是非原子的。
-
原生批量命令是一个命令对应多个key,Pipeline支持多个命令。
-
原生批量命令是Redis服务端支持实现的,而Pipeline需要服务端和客户端的共同实现。