交易系统中的本地缓存
常见缓存类型,可以分为本地缓存以及分布式缓存两种,Caffeine就是一种优秀的本地缓存,而Redis可以用来做分布式缓存。Caffeine是Google开发的,是在Guava cache的基础上改良而来的,底层设计思路、功能和使用方式与Guava非常类似,但是各方面的性能都要远远超过前者,可以看做是Guava cache的升级版,因此,之前使用过Guava cache,能够很快的上手Caffeine
在交易系统中,为了响应速度,大量使用了本地JVM缓存,底层是使用了Caffeine。公司在此基础上作了封装,实现了分布式环境中多个节点缓存同步,实际上在交易系统缓存的很多使用场景中,JVM中的缓存配置数据是常驻内存的,并不需要淘汰,因为配置数据量不大,而且使用频率高。在一个股票下单流程中,实际上要查很多外部数据,基本上全是基于内存实现,而且这些外部数据的特点是变化不是很频繁(例如某天行情发生剧烈波动,为避免风险,运营可能在盘中交易时段对标的限制交易,只允许平仓,不允许建仓),所以每次流程都必须要查。举个例子:用户在App对特斯拉发起下单时,需要查以下外部数据,
- 行情信息 : 查实时行情价,校验下单价格是否满足条件,买卖盘价格等
- 标的信息 : 查询标的是否可交易,是否可做空,如果是期权还要查底层标的物
- 账户信息 : 查询账户是否可以下单,账户是否支持卖空,账户是否开通了相关品类交易权限
- 配置信息 : 简单的配置项,如查询订单金额是否过低,订单委托数量是否太大(例如不允许散户单笔订单超过100万),此外还有一些功能模块的配置
a). 费用配置信息 : 各个市场收费配置不同,例如HK有印花税,US有平台费,佣金等
b). 灰度配置信息 : 与业务无关的配置,在做新功能时要做灰度,每次下单要查用户是否在灰度功能中
c). 风控标识信息 : 当标的和账户被风控打标,会通过消息同步给订单,在下单时要交易是否满足无风险标识
d). 交易日历信息 : 不同的市场,不同品类交易日历和交易时段都不同,盘中下单和盘前盘后下单有些流程处理不同,交易日历和交易时段也是属于高频查询数据
这里面有些使用了JVM内存,有些使用了堆外内存(即redis实现,例如行情数据这种数据量很庞大,不可能放在JVM里),在实际生产场景中,我们对热点标的的行情保存在了JVM中,例如苹果,特斯拉,谷歌等,因为这些symbol下单量大,每次下单查redis对于我们的消耗也挺大,于是优先查JVM,然后再查redis。
公司使用的缓存主要解决两个业务场景
1. 配置信息缓存,这类缓存一般数据了比较小,且变化不频繁。所以这类缓存一般是常驻JVM的,无需淘汰策略,但是需要关注多JVM节点的实时同步update
2. 热点数据缓存,这类缓存一般数据了比较大,且变化不频繁。所以只能放高频热点数据到JVM中,淘汰策略使用Caffeine提供的算法,同时也需要关注需多JVM节点的实时同步update
3. 外部数据缓存,这类缓存一般数据了比较大,且变化很频繁。行情数据是个很典型的例子,标的行情数据特点是实时变化且频繁,以前的做法是有一个行情服务,交易所的实时行情变化会同步到这个行情服务,订单每次下单时去查行情服务。每次都是远程调用,优化后改成了用docker的sidercar模式部署行情服务,这样可以本地调用,即 localhost访问这个服务,减少远程调用时间(行情服务用redis保存行情数据)
本文主要记录Caffeine的原理,包含三部分
- Caffeine的配置和使用
- SpringBoot整合Caffeine
- Caffeine + RabbitMQ分布式本地缓存
第3点是公司对于实时交易系统的业务场景对Caffeine做了一层封装,挺有意思,其实这也是使用本地缓存最大的问题,多JVM节点中缓存数据同步问题。我会简单实现机制,如果要在生产环境中使用,还需要考虑可靠性,稳定性,数据一致性等问题
Caffeine 简介
对比Guava cache的性能主要优化项
异步策略
Guava cache在读操作中可能会触发淘汰数据的清理操作,虽然自身也做了一些优化来减少读的时候的清理操作,但是一旦触发,就会降低查询效率,对缓存性能产生影响。而在Caffeine支持异步操作,采用异步处理的策略,查询请求在触发淘汰数据的清理操作后,会将清理数据的任务添加到独立的线程池中进行异步操作,不会阻塞查询请求,提高了查询性能。
ConcurrentHashMap优化
Caffeine底层都是通过ConcurrentHashMap来进行数据的存储,因此随着Java8中对ConcurrentHashMap的调整,数组+链表的结构升级为数组+链表+红黑树的结构以及分段锁升级为syschronized+CAS,降低了锁的粒度,减少了锁的竞争,这两个优化显著提高了Caffeine在读多写少场景下的查询性能
3.3 关于性能
性能上,关键的设计是顺序访问队列、异步化读写,分层时间轮、代码生成等。具体见:Design zh CN · ben-manes/caffeine Wiki · GitHub
Caffeine实现架构
Caffeine基础使用
使用Caffeine,需要在工程中引入如下依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<!--https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeinez找最新版-->
<version>3.0.5</version>
</dependency>
缓存加载策略
Caffeine提供了几种不同的加载策略
1 Cache手动创建
最普通的一种缓存,无需指定加载方式,需要手动调用put()进行加载。需要注意的是put()方法对于已存在的key将进行覆盖,这点和Map的表现是一致的。在获取缓存值时,如果想要在缓存值不存在时,原子地将值写入缓存,则可以调用get(key, k -> value)方法,该方法将避免写入竞争。调用invalidate()方法,将手动移除缓存。
在多线程情况下,当使用get(key, k -> value)时,如果有另一个线程同时调用本方法进行竞争,则后一线程会被阻塞,直到前一线程更新缓存完成;而若另一线程调用getIfPresent()方法,则会立即返回null,不会被阻塞。
Cache<Object, Object> cache = Caffeine.newBuilder()
//初始数量
.initialCapacity(10)
//最大条数
.maximumSize(10)
//expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准
//最后一次写操作后经过指定时间过期
.expireAfterWrite(1, TimeUnit.SECONDS)
//最后一次读或写操作后经过指定时间过期
.expireAfterAccess(1, TimeUnit.SECONDS)
//监听缓存被移除
.removalListener((key, val, removalCause) -> { })
//记录命中
.recordStats()
.build();
cache.put("1","张三");
//张三
System.out.println(cache.getIfPresent("1"));
//存储的是默认值
System.out.println(cache.get("2",o -> "默认值"));
2 Loading Cache自动创建
LoadingCache是一种自动加载的缓存。其和普通缓存不同的地方在于,当缓存不存在/缓存已过期时,若调用get()方法,则会自动调用CacheLoader.load()方法加载最新值。调用getAll()方法将遍历所有的key调用get(),除非实现了CacheLoader.loadAll()方法。使用LoadingCache时,需要指定CacheLoader,并实现其中的load()方法供缓存缺失时自动加载。
在多线程情况下,当两个线程同时调用get(),则后一线程将被阻塞,直至前一线程更新缓存完成。
LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
//创建缓存或者最近一次更新缓存后经过指定时间间隔,刷新缓存;refreshAfterWrite仅支持LoadingCache
.refreshAfterWrite(10, TimeUnit.SECONDS)
.expireAfterWrite(10, TimeUnit.SECONDS)
.expireAfterAccess(10, TimeUnit.SECONDS)
.maximumSize(10)
//根据key查询数据库里面的值,这里是个lamba表达式
.build(key -> new Date().toString());
3 Async Cache异步获取
AsyncCache是Cache的一个变体,其响应结果均为CompletableFuture,通过这种方式,AsyncCache对异步编程模式进行了适配。默认情况下,缓存计算使用ForkJoinPool.commonPool()作为线程池,如果想要指定线程池,则可以覆盖并实现Caffeine.executor(Executor)方法。synchronous()提供了阻塞直到异步缓存生成完毕的能力,它将以Cache进行返回。
在多线程情况下,当两个线程同时调用get(key, k -> value),则会返回同一个CompletableFuture对象。由于返回结果本身不进行阻塞,可以根据业务设计自行选择阻塞等待或者非阻塞。
AsyncLoadingCache<String, String> asyncLoadingCache = Caffeine.newBuilder()
//创建缓存或者最近一次更新缓存后经过指定时间间隔刷新缓存;仅支持LoadingCache
.refreshAfterWrite(1, TimeUnit.SECONDS)
.expireAfterWrite(1, TimeUnit.SECONDS)
.expireAfterAccess(1, TimeUnit.SECONDS)
.maximumSize(10)
//根据key查询数据库里面的值
.buildAsync(key -> {
Thread.sleep(1000);
return new Date().toString();
});
//异步缓存返回的是CompletableFuture
CompletableFuture<String> future = asyncLoadingCache.get("1");
future.thenAccept(System.out::println);
驱逐策略
驱逐策略在创建缓存的时候进行指定。常用的有基于容量的驱逐和基于时间的驱逐。基于容量的驱逐需要指定缓存容量的最大值,当缓存容量达到最大时,Caffeine将使用LRU策略对缓存进行淘汰;基于时间的驱逐策略如字面意思,可以设置在最后访问/写入一个缓存经过指定时间后,自动进行淘汰。驱逐策略可以组合使用,任意驱逐策略生效后,该缓存条目即被驱逐。
- LRU 最近最少使用,淘汰最长时间没有被使用的页面。
- LFU 最不经常使用,淘汰一段时间内使用次数最少的页面
- FIFO 先进先出
Caffeine有4种缓存淘汰设置
- 大小 (LFU算法进行淘汰)
- 权重 (大小与权重 只能二选一)
- 时间 (用的比较多 例如热点数据)
- 引用 (不常用)
public class CacheTest {
/**
* 缓存大小淘汰
*/
public void maximumSizeTest() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
//超过10个后会使用W-TinyLFU算法进行淘汰
.maximumSize(10)
.evictionListener((key, val, removalCause) -> {
log.info("淘汰缓存:key:{} val:{}", key, val);
})
.build();
for (int i = 1; i < 20; i++) {
cache.put(i, i);
}
Thread.sleep(500);//缓存淘汰是异步的
// 打印还没被淘汰的缓存
System.out.println(cache.asMap());
}
/**
* 权重淘汰
*/
public void maximumWeightTest() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
//限制总权重,若所有缓存的权重加起来>总权重就会淘汰权重小的缓存
.maximumWeight(100)
.weigher((Weigher<Integer, Integer>) (key, value) -> key)
.evictionListener((key, val, removalCause) -> {
log.info("淘汰缓存:key:{} val:{}", key, val);
})
.build();
//总权重其实是=所有缓存的权重加起来
int maximumWeight = 0;
for (int i = 1; i < 20; i++) {
cache.put(i, i);
maximumWeight += i;
}
System.out.println("总权重=" + maximumWeight);
Thread.sleep(500);//缓存淘汰是异步的
// 打印还没被淘汰的缓存
System.out.println(cache.asMap());
}
/**
* 访问后到期(每次访问都会重置时间,如果一直被访问就不会被淘汰)
*/
public void expireAfterAccessTest() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.SECONDS)
//可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
//若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
.scheduler(Scheduler.systemScheduler())
.evictionListener((key, val, removalCause) -> {
log.info("淘汰缓存:key:{} val:{}", key, val);
})
.build();
cache.put(1, 2);
System.out.println(cache.getIfPresent(1));
Thread.sleep(3000);
System.out.println(cache.getIfPresent(1));//null
}
/**
* 写入后到期
*/
public void expireAfterWriteTest() throws InterruptedException {
Cache<Integer, Integer> cache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
//可以指定调度程序来及时删除过期缓存项,而不是等待Caffeine触发定期维护
//若不设置scheduler,则缓存会在下一次调用get的时候才会被动删除
.scheduler(Scheduler.systemScheduler())
.evictionListener((key, val, removalCause) -> {
log.info("淘汰缓存:key:{} val:{}", key, val);
})
.build();
cache.put(1, 2);
Thread.sleep(3000);
System.out.println(cache.getIfPresent(1));//null
}
}
刷新机制
refreshAfterWrite()表示x秒后自动刷新缓存的策略可以配合淘汰策略使用,注意的是刷新机制只支持LoadingCache和AsyncLoadingCache
private static int NUM = 0;
public void refreshAfterWriteTest() throws InterruptedException {
LoadingCache<Integer, Integer> cache = Caffeine.newBuilder()
.refreshAfterWrite(1, TimeUnit.SECONDS)
//模拟获取数据,每次获取就自增1
.build(integer -> ++NUM);
//获取ID=1的值,由于缓存里还没有,所以会自动放入缓存
System.out.println(cache.get(1));// 1
// 延迟2秒后,理论上自动刷新缓存后取到的值是2
// 但其实不是,值还是1,因为refreshAfterWrite并不是设置了n秒后重新获取就会自动刷新
// 而是x秒后&&第二次调用getIfPresent的时候才会被动刷新
Thread.sleep(2000);
System.out.println(cache.getIfPresent(1));// 1
//此时才会刷新缓存,而第一次拿到的还是旧值
System.out.println(cache.getIfPresent(1));// 2
}
SpringBoot整合Caffeine
SpringBoot支持零配置,需要用@Cacheable注解,需要引入相关依赖,并在任一配置类文件上添加@EnableCaching注解
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
开启缓存功能
开启缓存功能,需要先添加使能注解 @EnableCaching,通常习惯在启动类配置,否则缓存注解@Cacheable 等不起作用
@EnableCaching
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
}
配置缓存类
@Configuration
public class CacheConfig {
@Bean("caffeineCacheManager")
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
.expireAfterWrite(3, TimeUnit.SECONDS));
return cacheManager;
}
}
使用缓存
@Service
@CacheConfig(cacheNames = "caffeineCacheManager")
public class UserServiceImpl3 {
// 模拟数据库数据
private Map<Integer, User> userMap = new HashMap<>();
@CachePut(key = "#user.id")
public User add(User user) {
log.info("add");
userMap.put(user.getId(), user);
return user;
}
@Cacheable(key = "#id")
public User get(Integer id) {
log.info("get");
return userMap.get(id);
}
@CachePut(key = "#user.id")
public User update(User user) {
log.info("update");
userMap.put(user.getId(), user);
return user;
}
@CacheEvict(key = "#id")
public void delete(Integer id) {
log.info("delete");
userMap.remove(id);
}
}
常用注解
- @Cacheable 【创建、查询缓存】:表示该方法支持缓存。当调用被注解的方法时,如果对应的键已经存在缓存,则不再执行方法体,而从缓存中直接返回。当方法返回null时,将不进行缓存操作。
- @CachePut 【更新缓存】:表示执行该方法后,其值将作为最新结果更新到缓存中,每次都会执行该方法。
- @CacheEvict 【删除缓存】:表示执行该方法后,将触发缓存清除操作。
- @Caching 【组合缓存配置】:组合前面三个注解,属性可以同时配置前面三个注解的功能
- @CacheConfig 【类级别共享配置】:在类级别设置一些缓存相关的共同配置(与其它缓存配合使用),避免在每个缓存方法上重复配置相同的缓存属性
配置数据源
这里扩展一下,spring-boot-starter-cache这个包是spring boot定义的本地缓存规范,具体的实现可以是caffeine, 也可以是redis,也就是说我们可以给缓存配置不同的数据源
<!-- Cache公共依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- 配置redis缓存时 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 配置本地caffeine缓存时 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
不配置(默认)
如果项目没有第三方缓存源依赖时,SpringBoot 会默认配置 ConcurrentMapCacheManager 缓存管理器,其内部由 ConcurrentHashMap存储缓存数据,如果有第三方缓存依赖,例如:caffeine、redis 时,就会相应的配置 CaffeineCacheManager 或 RedisCacheManager做默认缓存管理器
配置 Caffeine 缓存
缓存配置有两种:
- CaffeineCacheManager:使用一个全局的 Caffeine 配置,来创建所有的缓存。不能为每个方法,单独配置缓存过期时间,但可以在程序启动时,全局的配置缓存,方便设置所有方法的缓存过期时间
- SimpleCacheManager:当应用程序启动时,通过配置多个 CaffeineCache 来创建多个缓存。可以为每个方法单独配置缓存过期时间
CaffeineCacheManager配置
@Configuration
public class CacheConfig {
@Bean("caffeineCacheManager")
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
.expireAfterWrite(3, TimeUnit.SECONDS));
return cacheManager;
}
}
SimpleCacheManager配置
配置多个 CaffeineCache 来创建多个缓存
@Bean(name = "simpleCacheManager")
public SimpleCacheManager simpleCacheManager() {
SimpleCacheManager result = new SimpleCacheManager();
CaffeineCache users = new CaffeineCache("users",
Caffeine.newBuilder()
.expireAfterWrite(600, TimeUnit.SECONDS)
.maximumSize(10000L).build());
CaffeineCache roles = new CaffeineCache("roles",
Caffeine.newBuilder()
.expireAfterWrite(600, TimeUnit.SECONDS)
.maximumSize(10000L).build());
result.setCaches(Arrays.asList(users, roles));
return result ;
}
Redis 缓存配置
@Bean(name = "redisCacheManager")
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(600))
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues()
.prefixCacheNameWith("mtr");
RedisCacheManager redisCacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware()
.build();
return redisCacheManager;
}
配置多缓存源
@Configuration
public class CacheConfig {
@Bean(name = "simpleCacheManager")
public SimpleCacheManager simpleCacheManager() {
SimpleCacheManager result = new SimpleCacheManager();
CaffeineCache users = new CaffeineCache("users",
Caffeine.newBuilder()
.expireAfterWrite(600, TimeUnit.SECONDS)
.maximumSize(10000L).build());
CaffeineCache roles = new CaffeineCache("roles",
Caffeine.newBuilder()
.expireAfterWrite(600, TimeUnit.SECONDS)
.maximumSize(10000L).build());
result.setCaches(Arrays.asList(users, roles));
return result ;
}
@Bean(name = "redisCacheManager")
@Primary
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(600))
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues()
.prefixCacheNameWith("mtr");
RedisCacheManager redisCacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware()
.build();
return redisCacheManager;
}
}
【注意】:如果使用了多个 cahce,比如:redis、caffeine 等,必须指定某一个 CacheManage 为 @primary,在@Cacheable 注解中没指定 cacheManager 则使用标记为 primary的那个。
Caffeine分布式本地缓存
在生产环境中,JVM是多节点的,本地缓存更新只会更新本节点的JVM数据,如何把本节点的数据更新告知给其他节点,实现本地缓存多节点实时同步。需要引入消息总线,用RabbitMQ做总线。RabbitMQ的特点是设计非常轻量级,启动速度快,占用资源少,这使得它在各种环境下都能快速部署和运行。无论是开发环境还是生产环境,RabbitMQ的安装和配置都非常简单友好。要设计一个分布式本地缓存要解决以下问题
1.当一个节点update cache时,增量数据广播告知其他节点
2.要有一个全量刷新所有节点缓存的机制,刷新时间由业务方决定。同时也支持手动触发全量刷新
类设计上,顶层三个抽象类
- AbstractLocalCache<K,V> : 缓存抽象类,所有业务缓存都需要实现这个类
- LocalCacheEventBus : 缓存总线消息,用于缓存变更时通知,可以是rabbit push消息通知,也可以是redis push消息通知
- CacheManager
: 管理所有的缓存实现类,以及生命周期,并提供一些后台手工刷新操作