随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时,我们的应用服务器和数据库服务器所做的计算也越来越多,但是,往往我们的应用服务器的资源是有限的,而且技术变革是缓慢的,所以每秒能接收请求次数也是有限的,或者说文件的读写也是有限的。
如何能有效利用有限的资源来提供尽可能大的吞吐量呢?一个有效的办法就是引入缓存,打破图中的标准的流程,每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少他们的计算量,来有效提升响应速度,让有限的资源服务更多的用户,像我们这个图里展示的缓存的使用,它其实可以出现在1到4的各个环节中。
命中率:命中数/(命中数+没有命中数)
首先是命中,命中的还可以简单的理解为直接通过缓存获得到需要的数据,有了命中就有不命中,无法通过缓存的获取想要的数据,需要再次查询数据库,或者执行其他操作,原因可能是缓存中根本不存在,或者缓存已经过期了。
通常 命中率=命中数/(命中数+没有命中数) 来表达,能力越高,表示我们使用缓存的收益越高,应用的性能越好,这时候响应的时间会越短,吞吐量越来越高,抗并发的能力也越强,由此可见在高并发的互联网系统中,命中率是至关重要的一个指标。
最大元素(空间)
它代表的是缓存中可以存放的最大元素的数量,一旦缓存数量超过这个值,或则所占的空间,超过了最大支持的空间,就会促发缓存清空策略,根据不同的场景合理的设置最大元素值,往往可以一定程度上提高缓存的命中率,从而更有效的使用缓存。
像我们刚才所描述的,缓存的存储空间是有限制的,当缓存空间满时,如何保证在稳定服务的同时有效的提高命中率呢?这就有缓存的清空策略来处理,适合自身数据特征的清空策略,能有效的提高命中率。常见的清空策略为下面所示
清空策略:FIFO,LFU,LRU,过期时间,随机等
缓存在很多情况下非常实用。例如,计算或检索一个值的代价很高,并且对同样的输入需要不止一次获取值的时候,就应当考虑使用缓存。
Guava Cache与ConcurrentMap很相似,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所添加的元素,直到显式的移除;Guava Cache为了限制内存的占用,通常都是设定为自动回收元素。在某些场景下,尽管LoadingCahe不回收元素,但是它还是很有用的,因为它会自动加载缓存。
Guava Cache适用场景:
Guava Cache是一个全内存的本地缓存实现,它提供了线程安全的实现机制。整体上来说Guava cache 是本地缓存的不二之选,简单易用,性能好。
package com.mmall.concurrency.example.cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
@Slf4j
public class GuavaCacheExample1 {
public static void main(String[] args) {
LoadingCache<String, Integer> cache = CacheBuilder.newBuilder()
.maximumSize(10) // 最多存放10个数据
.expireAfterWrite(10, TimeUnit.SECONDS) // 缓存10秒
.recordStats() // 开启记录状态数据功能
.build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) throws Exception {
return -1;
}
});
log.info("{}", cache.getIfPresent("key1")); // null
cache.put("key1", 1);
log.info("{}", cache.getIfPresent("key1")); // 1
cache.invalidate("key1");//丢弃某个缓存值
log.info("{}", cache.getIfPresent("key1")); // null
try {
log.info("{}", cache.get("key2")); // -1
cache.put("key2", 2);
log.info("{}", cache.get("key2")); // 2
log.info("{}", cache.size()); // 1
for (int i = 3; i < 13; i++) {
cache.put("key" + i, i);
}
log.info("{}", cache.size()); // 10
// getIfPresent返回这个缓存中与{@code key}关联的值,如果{@code key}没有缓存值
// ,则返回{@code null}。
log.info("{}", cache.getIfPresent("key2")); // null
Thread.sleep(11000);
log.info("{}", cache.get("key5")); // -1
//返回返回查找方法返回缓存值的次数,返回查找方法返回未加值(新加载值)或null的次数。
log.info("{},{}", cache.stats().hitCount(), cache.stats().missCount());
//返回被命中的缓存请求的比率 ,返回未命中的缓存请求的比率。
log.info("{},{}", cache.stats().hitRate(), cache.stats().missRate());
} catch (Exception e) {
log.error("cache exception", e);
}
}
}
/*
15:00:31.976 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - null
15:00:32.031 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - 1
15:00:32.034 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - null
15:00:32.081 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - -1
15:00:32.082 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - 2
15:00:32.083 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - 1
15:00:32.091 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - 10
15:00:32.091 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - null
15:00:43.092 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - -1
15:00:43.092 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - 2,5
15:00:43.092 [main] INFO com.mmall.concurrency.example.cache.GuavaCacheExample1 - 0.2857142857142857,0.7142857142857143
*/
package com.mmall.concurrency.example.cache;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
@Slf4j
public class GuavaCacheExample2 {
public static void main(String[] args) {
Cache<String, Integer> cache = CacheBuilder.newBuilder()
.maximumSize(10) // 最多存放10个数据
.expireAfterWrite(10, TimeUnit.SECONDS) // 缓存10秒
.recordStats() // 开启记录状态数据功能
.build();
log.info("{}", cache.getIfPresent("key1")); // null
cache.put("key1", 1);
log.info("{}", cache.getIfPresent("key1")); // 1
cache.invalidate("key1");
log.info("{}", cache.getIfPresent("key1")); // null
try {
log.info("{}", cache.get("key2", new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return -1;
}
})); // -1
cache.put("key2", 2);
log.info("{}", cache.get("key2", new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return -1;
}
})); // 2
log.info("{}", cache.size()); // 1
for (int i = 3; i < 13; i++) {
cache.put("key" + i, i);
}
log.info("{}", cache.size()); // 10
log.info("{}", cache.getIfPresent("key2")); // null
Thread.sleep(11000);
log.info("{}", cache.get("key5", new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return -1;
}
})); // -1
log.info("{},{}", cache.stats().hitCount(), cache.stats().missCount());
log.info("{},{}", cache.stats().hitRate(), cache.stats().missRate());
} catch (Exception e) {
log.error("cache exception", e);
}
}
}
memcache是一套分布式的高速缓存系统,由LiveJournal的Brad Fitzpatrick开发,但目前被许多网站使用以提升网站的访问速度,尤其对于一些大型的、需要频繁访问数据库的网站访问速度提升效果十分显著。
MemCache的工作流程如下:先检查客户端的请求数据是否在memcached中,如有,直接把请求数据返回,不再对数据库进行任何操作;如果请求的数据不在memcached中,就去查数据库,把从数据库中获取的数据返回给客户端,同时把数据缓存一份到memcached中(memcached客户端不负责,需要程序明确实现);每次更新数据库的同时更新memcached中的数据,保证一致性;当分配给memcached内存空间用完之后,会使用LRU(Least Recently Used,最近最少使用)策略加上到期失效策略,失效数据首先被替换,然后再替换掉最近未使用的数据。
Memcache是一个高性能的分布式的内存对象缓存系统,通过在内存里维护一个统一的巨大的hash表,它能够用来存储各种格式的数据,包括图像、视频、文件以及数据库检索的结果等。简单的说就是将数据调用到内存中,然后从内存中读取,从而大大提高读取速度。
内存结构图:
这张图片里面涉及了slab_class、slab、page、chunk四个概念,它们之间的关系是:
1、MemCache将内存空间分为一组slab
2、每个slab下又有若干个page,每个page默认是1M,如果一个slab占用100M内存的话,那么这个slab下应该有100个page
3、每个page里面包含一组chunk,chunk是真正存放数据的地方,同一个slab里面的chunk的大小是固定的
4、有相同大小chunk的slab被组织在一起,称为slab_class
redis是一个key-value存储系统。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis支持各种不同方式的排序。与memcached一样,为了保证效率,数据都是缓存在内存中。区别的是redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。
1、速度快
Redis是用C语言实现的;
Redis的所有数据存储在内存中。
2、持久化
Redis的所有数据存储在内存中,对数据的更新将异步地保存到磁盘上。
3、支持多种数据结构
Redis支持五种数据结构:String、List、Set、Hash、Zset
4、支持多种编程语言
Java、php、Python、Ruby、Lua、Node.js
5、功能丰富
除了支持五种数据结构之外,还支持事务、流水线、发布/订阅、消息队列等功能。
6、源码简单
约23000行C语言源代码。
7、主从复制
主服务器(master)执行添加、修改、删除,从服务器执行查询。
8、高可用及分布式
Redis-Sentinel(v2.8)支持高可用
Redis-Cluster(v3.0)支持分布式
application.properties文件
#redis
jedis.host=127.0.0.1
jedis.port=6379
RedisConfig
package com.mmall.concurrency.example.cache;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;
@Configuration
public class RedisConfig {
@Bean(name = "redisPool")
public JedisPool jedisPool(@Value("${jedis.host}") String host,
@Value("${jedis.port}") int port) {
return new JedisPool(host, port);
}
}
RedisClient
package com.mmall.concurrency.example.cache;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import javax.annotation.Resource;
// http://redis.cn/
@Component
public class RedisClient {
@Resource(name = "redisPool")
private JedisPool jedisPool;
public void set(String key, String value) throws Exception {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.set(key, value);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
public String get(String key) throws Exception {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
return jedis.get(key);
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
CacheController (springboot框架访问)
package com.mmall.concurrency.example.cache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping("/cache")
public class CacheController {
@Autowired
private RedisClient redisClient;
@RequestMapping("/set")
@ResponseBody
public String set(@RequestParam("k") String k, @RequestParam("v") String v)
throws Exception {
redisClient.set(k, v);
return "SUCCESS";
}
@RequestMapping("/get")
@ResponseBody
public String get(@RequestParam("k") String k) throws Exception {
return redisClient.get(k);
}
}
当数据时效性要求很高时,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存。
缓存过期后将尝试从后端数据库获取数据,这是一个看似合理的流程。但是,在高并发场景下,有可能多个请求并发的去从数据库获取数据,对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。此外,当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致一致性的问题。那如何避免类似问题呢?我们会想到类似“锁”的机制,在缓存更新或者过期的情况下,先尝试获取到锁,当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据。
缓存穿透在有些地方也称为“击穿”。很多朋友对缓存穿透的理解是:由于缓存故障或者缓存过期导致大量请求穿透到后端数据库服务器,从而对数据库造成巨大冲击。
这其实是一种误解。真正的缓存穿透应该是这样的:
在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致巨大冲击和压力。
可以通过下面的几种常用方式来避免缓存传统问题:
缓存雪崩就是指由于缓存的原因,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。导致这种现象的原因有很多种,上面提到的“缓存并发”,“缓存穿透”,“缓存颠簸”等问题,其实都可能会导致缓存雪崩现象发生。这些问题也可能会被恶意攻击者所利用。还有一种情况,例如某个时间点内,系统预加载的缓存周期性集中失效了,也可能会导致雪崩。为了避免这种周期性失效,可以通过设置不同的过期时间,来错开缓存过期,从而避免缓存集中失效。
从应用架构角度,我们可以通过限流、降级、熔断等手段来降低影响,也可以通过多级缓存来避免这种灾难。此外,从整个研发体系流程的角度,应该加强压力测试,尽量模拟真实场景,尽早的暴露问题从而防范。
该问题由 facebook 的工作人员提出的, facebook 在 2010 年左右,memcached 节点就已经达3000 个,缓存数千 G 内容。他们发现了一个问题—memcached 连接频率,效率下降了,于是加 memcached 节点,添加了后,发现因为连接频率导致的问题,仍然存在,并没有好转,称之为”无底洞现象”。
目前主流的数据库、缓存、Nosql、搜索中间件等技术栈中,都支持“分片”技术,来满足“高性能、高并发、高可用、可扩展”等要求。有些是在client端通过Hash取模(或一致性Hash)将值映射到不同的实例上,有些是在client端通过范围取值的方式映射的。当然,也有些是在服务端进行的。但是,每一次操作都可能需要和不同节点进行网络通信来完成,实例节点越多,则开销会越大,对性能影响就越大。
主要可以从如下几个方面避免和优化:
当然,缓存无底洞现象并不常见。在绝大多数的公司里可能根本不会遇到。
作者:Jimin
链接:https://www.imooc.com/article/20918
来源:慕课网
来公司第一个比较大的业务需求,便是换了新的行情提供商,需要把所有K线的处理都重新倒腾一遍。这里说说重写分时K线图的一点心得。
先交代一下数据提供商的情况:
1)股票分时数据采用订阅的方式,可以订阅历史数据
2)每分钟每只股票可能会接收到多条分时数据,也可能一条不推。有些股票在开市过程中每分钟都会有分时数据,有些股票则一个交易日只有几条数据,甚至没有,差别很大
3)推送的分时数据有延迟,从监控获得数据,开市期间大约在60s-120s左右(分时数据看的是趋势,这是可以忍受的)
4)每个订阅对实时数据有qps限制,对历史数据无qps限制(从监控上得到的)
再交代一下股票分时K线数据的情况:
1)股票数目为8500+(美股相关)
2)每只股票1个交易日的分时线包含391条数据(9:30-16:00, America/New_York),每条数据包含这一分钟的最高价、最低价、均价、昨收价、涨幅、交易量、VWAP等,数据量在150k左右
3)每天存储的分时数据在120w条左右
4)股票分时线如果某一分钟没有点,使用上一分钟的点代替,但无交易量
接下来重点说一下这里的设计:(以AAPL为例)
1)由于分时数据有延迟,无法确定每一分钟的最后一条数据什么时候会来到。因此开市期间并不去保存分时数据。实时的分时数据存储在redis中,计算K线时从redis中取
2)redis中分时数据的存储格式为hash结构,每只股票一个key,为了防止当前交易日使用上个交易日的数据,因此分时数据缓存时是包含日期的,比如:TMT_AAPL0105, key为美东时间,格式:HHmm, value为简单计算后的分时数据。上一个交易日的分时数据通过定时任务在指定时间清理。(这里没有对分时数据做过期设置,是因为刚开市时数据推送量及redis操作量很大,而使用hashes这个结构,是不支持直接传入expire time的,同时即使某天系统出什么问题,也只是多占些硬盘的问题,不会对其他造成影响,对这个操作添加监控及报警就可以了)
3)结构有了,如果第三方数据提供商每次都推送数据过来时,都去操作redis更新,那样redis很可能会吃不消,谁都无法晓得第三方是否会出什么问题疯狂推送一下(事实证明,第三方数据提供商也确实这样有过,可能因网络故障等补偿推送历史数据)。接下来是缓存的的重点:在内存中通过guava cache缓存了最近几分钟的所有symbol的最新数据,key: 股票+HHmm(美东时间), value:一条分时数据,每次推送分时数据过来时,根据股票及分时数据时间进行cache,过期数据(很久以前或非最新数据)或脏数据(相关数据为NaN)直接抛弃,然后通过ScheduledExecutorService维护一个1分钟1执行的job去将最近几分钟的分时数据更新到redis中。这样既解决了分时数据有延迟的问题,又保证了每分钟的redis更新数据量。
4)数据存储结束后,就是处理分时K线的数据了。当请求AAPL时,会先查看是否已经有cache了,比如C_TT_AAPL, 如果有取出cache中数据处理后返回,没有开始计算。计算会先取出上面存储的数据,即TMT_AAPL0105,然后计算当前k线的时间点(开市过程中从1个点慢慢增长到391个点),遍历HHmm去TMT_AAPL0105中取分时数据,如果有直接取出即可,如果没有使用上一个点生成(第一个记得特殊处理一下,没有需要拿上个交易日最后一个点补),计算完后转成json放到redis中,key为计算前查询的key: C_TT_AAPL,然后将结果返回。开市过程中缓存到下一分钟开始,闭市了缓存到下个交易日开始。由于这里缓存的是string,因此直接带上过期时间,之后就不用care了。如此,最核心的分时K线数据绝大部分都是在操作redis,每只股票每分钟只需要计算一次即可,当然这是基于分时K线主要看的是趋势。
5)上面说了,分时数据可以拿历史数据。这里我单独写了一个job,可以在给定开始时间后,取出期望的历史数据,然后更新历史分时数据。当实时推送出问题时,这个job可以用来在秒级获取历史数据来修复redis中的分时数据。实际上,这个job在故障中的表现远远超出预期,之前因为对实时那里做优化,几次都有点问题导致开市后实时数据获取或处理有问题,这时候启动这个拿历史分时的job,几秒内就可以保证redis中的数据变成最新,保证核心的分时图数据一直ok。
6)说到这里,分时数据还没保存。这里借助上面拿历史数据的job,在闭市后(北京时间凌晨5点以后)自动执行,更新redis中数据后开始插入数据库。由于插入量特别大,通过guava的RateLimiter控制写入速度在合理的qps值,慢慢更新就好啦
当然这不是全部,还有些细节,比如动态增加股票,就不多说了。此外,我单独补了许多核心监控,比如定时任务是否正常执行、读取及更新redis时间、开市期间推送的qps、计算分时K线平均时间、平均每分钟推送不同股票数目等等,目前达到的结果是:docker上部署的2个4g的服务来处理所有K线图,分时K线图数据可以在平均10ms左右返回,大量的分时数据在低峰时间端插入,服务器在开市闭市都没什么压力。