本地缓存更新方案探索

本地缓存更新方案探索与实践

本地缓存更新方案探索

1 背景

  • 大家在工作中是否遇到过某些业务数据需要频繁使用,但是数据量不大的情况,一般就是几十条甚至几百条这种。
  • 一般的解决方案就是业务维护数据,然后同步redis缓存,C端使用缓存的数据。但是这里不免会出现大key/热key的问题,另外还有缓存穿透、缓存击穿等问题。
  • 那么接下来我们一起探索一下如何解决上述问题吧。

2 方案探索

  • 首先我们评估数据量,发现这类数据一般只有百条左右。那么在技术选型上使用本地缓存无疑是最好的方案。现在应对C端场景基本选型的都是Caffeine。详见:https://blog.youkuaiyun.com/for62/article/details/147494533
  • 我们选择了本地缓存一方面可以抗大流量,做到无状态横向扩容。另一方面可以提高服务稳定性降低tp99。
  • 那么接下来我们就要设计缓存一致性的实现方案了,如何将redis中的数据近实时同步到本地缓存,C端只读本地缓存,可以降级读redis。
  • 这里我们参考长轮询实现配置中心的方案:https://mp.weixin.qq.com/s/YjvL0sUTGHxR3GJFqrP8qg。客户端长轮询监听服务端数据变更,感知到数据变更后更新本地缓存数据。设计图如下:
    在这里插入图片描述

2.1 初始化

  • 这里我们先假设刷新本地缓存的方法为:LocalCacheRefresher.refresh();
    public void refresh() {
        Caffeine<String, Object> cacheInfo = getLocalCacheInstance();
        String redisCacheKey = getRedisCacheKey();
        Set<String> keys = redisCache.hKeys(redisCacheKey);
        for (String key : keys) {
            String data = redisCache.hGet(redisCacheKey, key);
            cacheInfo.put(key, data);
        }
    }
  • 服务启动时数据加载
@Component
public class LocalCacheInitRunner implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) throws Exception {
        LocalCacheRefresher.refresh();
    }
    
}

2.2 实时更新

2.2.1 长轮询
  • 这里我们用长轮询的方案监听源数据的变更来刷新本地缓存。
2.2.1.1 client
@Slf4j
public class LongPollClient {

    private CloseableHttpClient httpClient;
    private RequestConfig requestConfig;

    public ConfigClient() {
        this.httpClient = HttpClientBuilder.create().build();
        // httpClient 客户端超时时间要大于长轮询约定的超时时间
        this.requestConfig = RequestConfig.custom().setSocketTimeout(6000).build();
    }

    public void longPolling(String url, String dataId) {
        String endpoint = url + "?dataId=" + dataId;
        HttpGet request = new HttpGet(endpoint);
        CloseableHttpResponse response = httpClient.execute(request);
        switch (response.getStatusLine().getStatusCode()) {
            case 200: {
                BufferedReader rd = new BufferedReader(new InputStreamReader(response.getEntity()
                    .getContent()));
                StringBuilder result = new StringBuilder();
                String line;
                while ((line = rd.readLine()) != null) {
                    result.append(line);
                }
                response.close();
                String configInfo = result.toString();
                log.info("dataId: [{}] changed, receive configInfo: {}", dataId, configInfo);
                longPolling(url, dataId);
                break;
            }
            // ② 304 响应码标记配置未变更
            case 304: {
                log.info("longPolling dataId: [{}] once finished, configInfo is unchanged, longPolling again", dataId);
                longPolling(url, dataId);
                break;
            }
            default: {
                throw new RuntimeException("unExcepted HTTP status code");
            }
        }

    }
}
2.2.2.2 server
@RestController
@Slf4j
@SpringBootApplication
public class LongPollServer {

    @Data
    private static class AsyncTask {
        // 长轮询请求的上下文,包含请求和响应体
        private AsyncContext asyncContext;
        // 超时标记
        private boolean timeout;

        public AsyncTask(AsyncContext asyncContext, boolean timeout) {
            this.asyncContext = asyncContext;
            this.timeout = timeout;
        }
    }

    // guava 提供的多值 Map,一个 key 可以对应多个 value
    private Multimap<String, AsyncTask> dataIdContext = Multimaps.synchronizedSetMultimap(HashMultimap.create());

    private ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("longPolling-timeout-checker-%d")
        .build();
    private ScheduledExecutorService timeoutChecker = new ScheduledThreadPoolExecutor(1, threadFactory);

    // 配置监听接入点
    @RequestMapping("/listener")
    public void addListener(HttpServletRequest request, HttpServletResponse response) {

        String dataId = request.getParameter("dataId");
        
        // 开启异步
        AsyncContext asyncContext = request.startAsync(request, response);
        AsyncTask asyncTask = new AsyncTask(asyncContext, true);

        // 维护 dataId 和异步请求上下文的关联
        dataIdContext.put(dataId, asyncTask);

        // 启动定时器,30s 后写入 304 响应
        timeoutChecker.schedule(() -> {
            if (asyncTask.isTimeout()) {
                dataIdContext.remove(dataId, asyncTask);
                response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
                asyncContext.complete();
            }
        }, 3000, TimeUnit.MILLISECONDS);
    }

    // 配置发布接入点
    @RequestMapping("/publishConfig")
    @SneakyThrows
    public String publishConfig(String dataId, String configInfo) {
        log.info("publish configInfo dataId: [{}], configInfo: {}", dataId, configInfo);
        Collection<AsyncTask> asyncTasks = dataIdContext.removeAll(dataId);
        for (AsyncTask asyncTask : asyncTasks) {
            asyncTask.setTimeout(false);
            HttpServletResponse response = (HttpServletResponse)asyncTask.getAsyncContext().getResponse();
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().println(configInfo);
            asyncTask.getAsyncContext().complete();
        }
        return "success";
    }

    public static void main(String[] args) {
        SpringApplication.run(ConfigServer.class, args);
    }

}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值