一、背景
某系统作为公司产品矩阵底座,每天面对数十亿级流量请求。在核心接口全链路压测时发现需多次通过网络请求分布式缓存,影响接口耗时。缓存、熔断、限流作为应对高并发系统的三板斧,其中熔断限流作为系统的自我保护机制,而缓存作为接口性能提升利器备受关注。
二、问题分析
分布式内存缓存读取相对访问db磁盘至少是数量级领先,但仍然存在网络请求,于是引入本地缓存形成本地缓存、分布式缓存与db形成三级存储结构。但任何事情都具有两面性,引入缓存后同一份数据会存储在不同的地方,带来数据一致性的问题。
目前数据一致性解决方案有强一致、弱一致及最终一致,强一致的需要额外的手段保障实现成本高,最终一致为为弱一致性的特例。结合当前业务场景及实现成本,此处选择最终一致性作为当前系统的一致性解决方案,放弃强一致不代表不追求一致性,会尽可能追求数据的一致性。
三、本地缓存选型
- LinkedHashMap
- 优点:jdk内置数据结构,无需引入其他组件,线程安全;
- 缺点:缺少容量限制、无淘汰策略,需要自己开发;
- Guava
- 优点:Guava作为Google团队开源的一款Java核心增强库,在性能和稳定性上都有保障,同时提供容量限制、两种淘汰策略(LRU和LFU);
- 缺点:淘汰策略不够完善,使用任何一种都存在一定问题。比如LRU在面对偶尔批量刷新数据时很容易将缓存内容挤出,带来击穿风险。LFU在面对突发流量时显得力不从心,需要时间累积使用频率。
- Caffeine
- 优点:caffeine以Guava为基础改进而来,站在巨人的肩膀上有更高的起点。在LFU基础上改进形成W-TinyLFU淘汰算法,在一定程度上解决了LFU的缺点,让caffeine有更高的缓存命中效率;
根据官方的测试数据来看,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来处理获得的相关数据。 综上所述,前3种方案存在问题,第四种可以接受但是需在执行MySQL变更时需要操作Redis操作刷新缓存,存在耦合,因此选用方案5。
六、redis与本地缓存同步
1、消息队列广播模式
当binlog有变更时推送消息到消息队列,应用实例采用广播模式消费,消费时抢锁更新Redis缓存,然后更新本地缓存;
-
优点:广播机制实现本地缓存更新;
-
缺点:依赖分布式锁、需要支持广播的消息队列;
2、基于redis发布订阅
当canal收到binlog变更时,将变更消息推送到消息队列,应用实例以集群模式消费后更新分布式缓存,然后向Redis集群发布消息。注册订阅的实例收到发布消息后更新本地缓存(覆盖或删除本地缓存),具体流程如上所示。
- 缺点:当订阅Redis集群的实例比较多时,更新本地缓存可能会存在时延;
- 优点:轻量;
目前消息队列技术栈选用的是Kafka,但Kafka无法支持广播消费(RocketMQ支持广播消费),同时广播消费需要分布式锁支持相对较重,因此此处选用Redis发布订阅模式。
七、方案实践
1、canal部署
此处在云环境搭建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集群并发量及执行命令数对比:
集群IP | 每秒并发量 | 每分钟命令数 |
---|---|---|
xx.78.32 | 4737—>1896 | 263400—>113400 |
xx.78.35 | 6184—>1716 | 351060—>103680 |
xx.78.36 | 6262—>2203 | 354840—>123540 |
xx.78.39 | 5409—>1656 | 307560—>101640 |
从redis执行并发量及命令数来看,本地缓存拦截很多请求,降低Redis集群的负载,提升响应速度。
- 根据内存dump计算出本地缓存占用内存约为60M左右;
- 核心接口耗时下降约为19%;
- redis命令数及并发量降低67%;
八、总结
经过分析选型,用redis与caffeine构建两级缓存,用canal解决db和redis之间的数据同步,用redis发布订阅解决分布式缓存与本地缓存之间的数据同步,构建近乎实时的两级缓存,接口性能得到约19%的提升,redis集群负载下降一半以上。