交易系统中的本地缓存

常见缓存类型,可以分为本地缓存以及分布式缓存两种,Caffeine就是一种优秀的本地缓存,而Redis可以用来做分布式缓存。Caffeine是Google开发的,是在Guava cache的基础上改良而来的,底层设计思路、功能和使用方式与Guava非常类似,但是各方面的性能都要远远超过前者,可以看做是Guava cache的升级版,因此,之前使用过Guava cache,能够很快的上手Caffeine

在交易系统中,为了响应速度,大量使用了本地JVM缓存,底层是使用了Caffeine。公司在此基础上作了封装,实现了分布式环境中多个节点缓存同步,实际上在交易系统缓存的很多使用场景中,JVM中的缓存配置数据是常驻内存的,并不需要淘汰,因为配置数据量不大,而且使用频率高。在一个股票下单流程中,实际上要查很多外部数据,基本上全是基于内存实现,而且这些外部数据的特点是变化不是很频繁(例如某天行情发生剧烈波动,为避免风险,运营可能在盘中交易时段对标的限制交易,只允许平仓,不允许建仓),所以每次流程都必须要查。举个例子:用户在App对特斯拉发起下单时,需要查以下外部数据,

  1. 行情信息 : 查实时行情价,校验下单价格是否满足条件,买卖盘价格等
  2. 标的信息 : 查询标的是否可交易,是否可做空,如果是期权还要查底层标的物
  3. 账户信息 : 查询账户是否可以下单,账户是否支持卖空,账户是否开通了相关品类交易权限
  4. 配置信息 : 简单的配置项,如查询订单金额是否过低,订单委托数量是否太大(例如不允许散户单笔订单超过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的原理,包含三部分

  1. Caffeine的配置和使用
  2. SpringBoot整合Caffeine
  3. 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实现架构
image

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.要有一个全量刷新所有节点缓存的机制,刷新时间由业务方决定。同时也支持手动触发全量刷新
image
类设计上,顶层三个抽象类

  • AbstractLocalCache<K,V> : 缓存抽象类,所有业务缓存都需要实现这个类
  • LocalCacheEventBus : 缓存总线消息,用于缓存变更时通知,可以是rabbit push消息通知,也可以是redis push消息通知
  • CacheManager : 管理所有的缓存实现类,以及生命周期,并提供一些后台手工刷新操作
posted @ 2025-04-22 14:47  街灯以北  阅读(30)  评论(0)    收藏  举报