基于canal与Redis发布订阅构建让应用起飞的多级缓存方案及实践

0 阅读10分钟

一、背景

       某系统作为公司产品矩阵底座,每天面对数十亿级流量请求。在核心接口全链路压测时发现需多次通过网络请求分布式缓存,影响接口耗时。缓存、熔断、限流作为应对高并发系统的三板斧,其中熔断限流作为系统的自我保护机制,而缓存作为接口性能提升利器备受关注。

二、问题分析

       分布式内存缓存读取相对访问db磁盘至少是数量级领先,但仍然存在网络请求,于是引入本地缓存形成本地缓存、分布式缓存与db形成三级存储结构。但任何事情都具有两面性,引入缓存后同一份数据会存储在不同的地方,带来数据一致性的问题。

     目前数据一致性解决方案有强一致、弱一致及最终一致,强一致的需要额外的手段保障实现成本高,最终一致为为弱一致性的特例。结合当前业务场景及实现成本,此处选择最终一致性作为当前系统的一致性解决方案,放弃强一致不代表不追求一致性,会尽可能追求数据的一致性。

三、本地缓存选型

  1. LinkedHashMap
  • 优点:jdk内置数据结构,无需引入其他组件,线程安全;
  • 缺点:缺少容量限制、无淘汰策略,需要自己开发;
  1. Guava
  • 优点:Guava作为Google团队开源的一款Java核心增强库,在性能和稳定性上都有保障,同时提供容量限制、两种淘汰策略(LRU和LFU);
  • 缺点:淘汰策略不够完善,使用任何一种都存在一定问题。比如LRU在面对偶尔批量刷新数据时很容易将缓存内容挤出,带来击穿风险。LFU在面对突发流量时显得力不从心,需要时间累积使用频率。
  1. Caffeine
  • 优点:caffeine以Guava为基础改进而来,站在巨人的肩膀上有更高的起点。在LFU基础上改进形成W-TinyLFU淘汰算法,在一定程度上解决了LFU的缺点,让caffeine有更高的缓存命中效率; file

        根据官方的测试数据来看,Caffeine在性能上优于其他几种组件,因此此处选用Caffeine作为本地缓存,Caffeine过期策略:

  • expireAfterAccess: 在指定的过期时间内没有读写,再次访问时会将数据判断为失效。如果一直访问则永不过期,不能单独使用;

  • expireAfterWrite: 在指定的过期时间内没有再次写入,再次访问时会将数据判断为失效,此时请求会阻塞等待加载新数据返回;

  • refreshAfterWrite: 在指定的过期时间后访问时,会再次加载刷新缓存数据,在刷新任务未完成之前,其他线程返回旧值;

上述任何一种策略单独使用都存在一定的问题,下面尝试将两者组合进行分析:

组合1:expireAfterAccess和expireAfterWrite 缺点:缓存过期后,由一个请求去执行后端查询,其他请求都在阻塞等待结果返回。如果同时有大量的请求阻塞,对系统吞吐量有一定影响。

组合2:expireAfterAccess和refreshAfterWrite 缺点:如果某个key一直被访问,则会一直不过期。

组合3:expireAfterWrite和refreshAfterWrite 将组合3两个过期时间比例设成3:1,可以在保证数据失效的同时可以防止大量请求因数据过期造成阻塞,因此选用组合3作为本地缓存的过期策略。

四、本地缓存选型

       常见的分布式缓存有Memcache、Redis等,但Redis相比Memcache有更丰富的功能特性(持久化、发布订阅、主从复制等), 因此选用Redis作为分布式缓存。

五、MySQL与Redis数据同步

1、先更新MySQL再更新Redis
  • 缺点:A、在高并发情况下,假设请求A先写MySQL然后卡顿,随后请求B写MySQL再更新Redis,请求A最后再更新Redis,会存在旧值覆盖新值;B、写完数据库立即写缓存,可能会存在浪费系统资源;
  • 是否推荐使用:不推荐
2、先更新Redis再更新MySQL
  • 缺点:更新Redis成功但写MySQL失败;
  • 是否推荐使用:不推荐
3、先删除Redis再写MySQL
  • 缺点:在高并发情况下,假设请求A先删除Redis然后卡顿,请求B请求Redis没值则读取MySQL,再缓存在Redis,然后请求A再写MySQL,则此时缓存中的值为脏数据。
  • 解决办法:延时双删
  • 是否推荐使用:不推荐
4、先写MySQL再删除Redis
  • 缺点1:假设写请求A先来,请求A写MySQL然后卡顿没来得及删除缓存,请求B读取缓存会是旧值,随后请求A就将缓存删除,B读取了一次旧值,可以接受;

  • 缺点2

  • 缓存到过期时间会自动失效;

  • 请求A查询缓存,发缓存中没有数据,查询数据库的旧值,但由于网络原因卡顿了,没有来得及更新缓存;

  • 请求B写数据库,接着删除了缓存;

  • 请求A更新旧值到缓存中;

    上述情形出现条件需要满足:缓存刚好失效请求A查询耗时比请求B更新数据库耗时时间还要长,出现概率极小。

  • 是否推荐使用:推荐
5、利用Canal监听MySQL binlog

        Canal是用Java开发的基于数据库增量日志解析,提供增量数据订阅&消费的中间件。目前Canal支持MySQL的binlog解析,解析完成后才利用Canal Client来处理获得的相关数据。 file       综上所述,前3种方案存在问题,第四种可以接受但是需在执行MySQL变更时需要操作Redis操作刷新缓存,存在耦合,因此选用方案5。

六、redis与本地缓存同步

1、消息队列广播模式

file         当binlog有变更时推送消息到消息队列,应用实例采用广播模式消费,消费时抢锁更新Redis缓存,然后更新本地缓存;

  • 优点:广播机制实现本地缓存更新;

  • 缺点:依赖分布式锁、需要支持广播的消息队列;

2、基于redis发布订阅

file         当canal收到binlog变更时,将变更消息推送到消息队列,应用实例以集群模式消费后更新分布式缓存,然后向Redis集群发布消息。注册订阅的实例收到发布消息后更新本地缓存(覆盖或删除本地缓存),具体流程如上所示。

  • 缺点:当订阅Redis集群的实例比较多时,更新本地缓存可能会存在时延;
  • 优点:轻量;

       目前消息队列技术栈选用的是Kafka,但Kafka无法支持广播消费(RocketMQ支持广播消费),同时广播消费需要分布式锁支持相对较重,因此此处选用Redis发布订阅模式。

七、方案实践

1、canal部署

file 此处在云环境搭建canal服务,示意图如上:

canal配置binlog监听

anal.instance.master.address:数据库db连接;

canal.instance.dbUsername:连接数据库账号;

canal.instance.dbPassword:连接数据库密码;

canal.instance.filter.regex:监听库中哪些表格的正则表达式;

canal.mq.topic:发送消息队列的主题;
2、消费kafka更新分布式缓存及发布变更:
  • 消费kafka:
 public void onMessage(List<String> messages) throws KafkaConsumeRetryException {
        if (CollectionUtils.isEmpty(messages)) {
            return;
        }
        log.info("[Binlog] receive msg from bin log {}", messages);
        for (String msg : messages) {
            BinlogKafkaDTO binlogKafkaDTO = JSONObject.parseObject(msg, BinlogKafkaDTO.class);
            //省略非关键代码
            ICouponCacheHandler iCouponCacheHandler = handleMap.get(binlogKafkaDTO.getTable());
            if (iCouponCacheHandler == null) {
                log.error("[Binlog] no handler find msg:{}", binlogKafkaDTO);
                continue;
            }
			//判断是否为插入还是更新,删除需走另外的逻辑
            boolean insertOrUpdate = SfStrUtil.equals(binlogKafkaDTO.getType(), CacheConstant.EventType.INSERT) || SfStrUtil.equals(binlogKafkaDTO.getType(), CacheConstant.EventType.UPDATE);
            iCouponCacheHandler.updateCache(multiLevelCacheUtil, binlogKafkaDTO, insertOrUpdate);
        }
    }
    
  default void updateCache(MultiLevelCacheUtil multiLevelCacheUtil, BinlogKafkaDTO binlogKafkaDTO, Boolean insertOrUpdate) {
        JSONArray jsonArray = binlogKafkaDTO.getData();
        for (int i = 0, num = jsonArray.size(); i < num; ++i) {
            BinlogBaseInfo obj = JSONObject.parseObject(JSONObject.toJSONString(jsonArray.get(i)), BinlogBaseInfo.class);
            String id = obj.getId();
            if (insertOrUpdate) {
                multiLevelCacheUtil.putForCache(String.format(getCachePrefix(), id), JSONObject.toJSONString(obj), CacheConstant.EXPIRE_SIZE);
            } else {
                //delete
                multiLevelCacheUtil.deleteCache(String.format(getCachePrefix(), id));
            }
        }
    }
  • 更新分布式缓存及发布变更:
public <T> void putForCache(String key, T value, long expireTime) {
        if (expireTime <= 0) {
            redisUtil.set(key, value);
        } else {
            redisUtil.set(key, value, expireTime);
        }

        redisUtil.getRedisTemplate().convertAndSend(key, value instanceof String ? value : SfJsonUtil.toJsonStr(value));
    }
 
   public void deleteCache(String key) {
        try {
            redisUtil.unlink(key);
            redisUtil.getRedisTemplate().convertAndSend(key, CacheConstant.DELETE_FLAG);
        } catch (Exception e) {
            log.error("deleteCache error key:{}", key, e);
        }
    }
3、向redis订阅注册及回调处理
  • 监听回调基类
public abstract class AbstractRedisMessageListener implements MessageListener {
private CaffeineCacheUtil caffeineCacheUtil;
public AbstractRedisMessageListener(CaffeineCacheUtil caffeineCacheUtil) {
this.caffeineCacheUtil = caffeineCacheUtil;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String data = LocalCacheConstant.VALUE_SERIALIZER.deserialize(message.getBody());
String channel = new String(message.getChannel());
//如果是删除redis缓存,则清除本地缓存 否则更新本地缓存或者插入 则直接覆盖
if (SfStrUtil.equals(LocalCacheConstant.DELETE_FLAG, data)) {
caffeineCacheUtil.evictCache(getCacheName(), channel);
} else {
caffeineCacheUtil.putCache(getCacheName(), channel, data);
}
}
public abstract Set<String> getTopics();

public abstract SubscribeType getSubscribeType();

public abstract String getCacheName();
}
  • 渠道黑名单监听实现如下:
@Slf4j
@Component
public class BlackWaybillSourceRedisMessageListener extends AbstractRedisMessageListener {
    public BlackIpRedisMessageListener(CaffeineCacheUtil caffeineCacheUtil) {
        super(caffeineCacheUtil);
    }
    @Override
    public Set<String> getTopics() {
        return Sets.newHashSet(LocalCacheConstant.RedisMessageTopic.BlackWaybillSource);
    }

    @Override
    public SubscribeType getSubscribeType() {
        return SubscribeType.CHANNEL_TYPE;
    }
    @Override
    public String getCacheName() {
        return LocalCacheConstant.CaffineCacheName.BlackWaybillSource;
    }
}

此处以将所有渠道黑名单作为集合当成一个key,则以频道注册。比如需缓存订单,需要以订单号维度缓存订单,则以模式注册。

  • 向redis订阅注册:
@Configuration
public class RedisMessageListenerConfig {


    @Bean
    @ConditionalOnBean(AbstractRedisMessageListener.class)
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory factory,List<AbstractRedisMessageListener> redisMessageListeners) {

        RedisMessageListenerContainer redisMessageListenercontainer = new RedisMessageListenerContainer();
        redisMessageListenercontainer.setConnectionFactory(factory);
        for (AbstractRedisMessageListener redisMessageListener : redisMessageListeners) {
            //判断是模式注册还是频道订阅
            boolean channelType = ObjectUtil.equals(SubscribeType.CHANNEL_TYPE, redisMessageListener.getSubscribeType());
            redisMessageListener.getTopics().stream().forEach(channel ->
                    redisMessageListenercontainer.addMessageListener(redisMessageListener, channelType ? new ChannelTopic(channel):new PatternTopic(channel)));
        }
        log.info("Register listener for redisMessageListenerContainer num:{}", redisMessageListeners.size());
        return redisMessageListenercontainer;
    }
}
4、缓存创建
  • 利用spring cacheManager批量创建缓存:
public CacheManager cacheManagerWithCaffeine() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        Caffeine caffeine = Caffeine.newBuilder()
            //cache的初始容量值
            .initialCapacity(StringUtils.isEmpty(initCapacity)?100:Integer.parseInt(initCapacity))
            //maximumSize用来控制cache的最大缓存数量,maximumSize和maximumWeight(最大权重)不可以同时使用,
            .maximumSize(StringUtils.isEmpty(maxSize)?1000:Long.parseLong(maxSize))
            //创建或更新之后多久刷新,需要设置cacheLoader
            .refreshAfterWrite(StringUtils.isEmpty(refreshAfterWrite)?10:Long.parseLong(refreshAfterWrite), TimeUnit.SECONDS);
        if (StringUtils.isNotBlank(expireAfterAccess)) {
            caffeine.expireAfterAccess(Long.parseLong(expireAfterAccess), TimeUnit.SECONDS);
        }
        if (StringUtils.isNotBlank(expireAfterWrite)) {
            caffeine.expireAfterWrite(Long.parseLong(expireAfterWrite), TimeUnit.SECONDS);
        }
        cacheManager.setCaffeine(caffeine);
        cacheManager.setCacheLoader(cacheLoader());
        if (StringUtils.isEmpty(cacheNames)){
            cacheNames = "userCache,commonCache";
        }
        // 根据名字可以创建多个cache,但是多个cache使用相同的策略
        cacheManager.setCacheNames(Arrays.asList(cacheNames.split(",")));
        // 是否允许值为空
        cacheManager.setAllowNullValues(false);
        return cacheManager;
    }

缓存参数参数设置:

caffine配置

caffeine.cacheNames=waybillSource

caffeine.initCapacity=32

caffeine.maxSize=64

#写入多久后过期

caffeine.expireAfterWrite=1800

#写入多久后过期过,同时请求先返回旧数据然后再加载

caffeine.refreshAfterWrite=600

        批量创建caffeine简单方便,但因追求通用性缺乏个性化定制.比如所有缓存过期时间、容量都是一致的,此时可以采用caffeine手动创建缓存进行深度定制,同样以渠道黑名单为例。

  • 根据api手动构建缓存:
@Component
public class BlackWaybillSourceCache {
    private LoadingCache<String, List< BlackWaybillSource>> cache = Caffeine.newBuilder()
            .refreshAfterWrite(10, TimeUnit.MINUTES)
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .initialCapacity(2)
            .maximumSize(2)
            .build(new CacheLoader<String, List< BlackWaybillSource>>() {
                @Nullable
                @Override
                public List<WaybillSource> load(@Nonnull String key) throws Exception {
                     .......
                    return JSONObject.parseArray(jsonString, BlackWaybillSource.class);
                }
            });

    public List< BlackWaybillSource> queryBlackWaybillSource() {
        return cache.get(WAYBILL_SOUERCE);
    }

    public void put(String key,String value){
        cache.put(key,JSONObject.parseArray(value,BlackWaybillSource.class));
    }

    public void evict(String key){
        cache.invalidate(key);
    }
}

        此时BlackWaybillSourceRedisMessageListener需实现onMessage,更新及删除分别删除上面WaybillSourceCache的put和evict方法实现回调更新。

5、效果

功能上线前后Redis集群并发量及执行命令数对比: file

集群IP每秒并发量每分钟命令数
xx.78.324737—>1896263400—>113400
xx.78.356184—>1716351060—>103680
xx.78.366262—>2203354840—>123540
xx.78.395409—>1656307560—>101640

      从redis执行并发量及命令数来看,本地缓存拦截很多请求,降低Redis集群的负载,提升响应速度。

  • 根据内存dump计算出本地缓存占用内存约为60M左右;
  • 核心接口耗时下降约为19%;
  • redis命令数及并发量降低67%;

八、总结

        经过分析选型,用redis与caffeine构建两级缓存,用canal解决db和redis之间的数据同步,用redis发布订阅解决分布式缓存与本地缓存之间的数据同步,构建近乎实时的两级缓存,接口性能得到约19%的提升,redis集群负载下降一半以上。