京东hotkey源码解析

写在前面

京东hotkey是一个经过京东大促验证的hotkey防御中间件,大概原理是通过上报key访问数到统计服务器集群,统计服务器集群将hotkey通知到客户端,让hotkey能缓存到本地内存中,做到毫秒级的Scale-Out。处理方式有点像美团cat实时收集数据进行统计,只不过美团cat没有反向通知逻辑而已。非常贴近工作实践,值得一看。


hotkey架构图

client端

首先看一下缓存入口Cache的get方法,JdHotKeyStore.getValue是获取hotkey的方法,并且会进行访问次数的统计上报,如果获取到hotkey不为空,则直接返回,否则从redis获取并调用JdHotKeyStore.smartSet判断是否有hotkey,有则设置值,最后返回。

    public String get(String key) {
        Object object = JdHotKeyStore.getValue(key);
        //如果已经缓存过了
        if (object != null) {
            return object.toString();
        } else {
            String value = getFromRedis(key);
            JdHotKeyStore.smartSet(key, value);
            return value;
        }
    }

JdHotKeyStore.getValue会先调用inRule校验此key是否有对应规则,如果没有对应规则则不处理,然后调用getValueSimple从本地内存中获取hotkey的存储对象ValueModel,如果没有获取到,则调用HotKeyPusher.push开始计数;如果获取到,会调用isNearExpire判断是否快过期了,如果是也计数,然后取出ValueModel里的value是否有设置对应值,有才返回。最后调用KeyHandlerFactory.getCounter().collect进行对应规则的计数。下面来一步步分析此流程。

    public static Object getValue(String key) {
        return getValue(key, null);
    }

 /**
     * 获取value,如果value不存在则发往netty
     */
    public static Object getValue(String key, KeyType keyType) {
        try {
            //如果没有为该key配置规则,就不用上报key
            if (!inRule(key)) {
                return null;
            }
            Object userValue = null;

            ValueModel value = getValueSimple(key);

            if (value == null) {
                HotKeyPusher.push(key, keyType);
            } else {
                //临近过期了,也发
                if (isNearExpire(value)) {
                    HotKeyPusher.push(key, keyType);
                }
                Object object = value.getValue();
                //如果是默认值,也返回null
                if (object instanceof Integer && Constant.MAGIC_NUMBER == (int) object) {
                    userValue = null;
                } else {
                    userValue = object;
                }
            }

            //统计计数
            KeyHandlerFactory.getCounter().collect(new KeyHotModel(key, value != null));

            return userValue;
        } catch (Exception e) {
            return null;
        }

    }

inRule会去KeyRule缓存中获取对应的规则,经过层层调用会到KeyRuleHolder的findByKey方法,然后继续调用其findRule方法选择对应的KeyRule,如果没有KeyRule就直接返回了,否则会拿到它的duration(hotkey缓存时间),拿到对应duration的本地缓存。实际上这里为了方法的通用性,用了get来代替contain的判断。

  /**
     * 判断这个key是否在被探测的规则范围内
     */
    private static boolean inRule(String key) {
        return CacheFactory.getCache(key) != null;
    }

    public static LocalCache getCache(String key) {
        return KeyRuleHolder.findByKey(key);
    }

    public static LocalCache findByKey(String key) {
        if (StrUtil.isEmpty(key)) {
            return null;
        }
        KeyRule keyRule = findRule(key);
        if (keyRule == null) {
            return null;
        }
        return RULE_CACHE_MAP.get(keyRule.getDuration());
    }

findRule的逻辑比较特别,作者已经留下了注释,优先全匹配->prefix匹配-> * 通配,这样做是为了更精确选择对应的规则。比如配置了sku_的前缀规则,但是茅台sku的流量突升,需要针对茅台sku的本地缓存再长一点时间让系统平稳渡过高峰期,那就配置一个sku_moutai_sku_id的全匹配规则,这样不会干扰到其他sku的缓存规则。

 //遍历该app的所有rule,找到与key匹配的rule。优先全匹配->prefix匹配-> * 通配
    private static KeyRule findRule(String key) {
        KeyRule prefix = null;
        KeyRule common = null;
        for (KeyRule keyRule : KEY_RULES) {
            if (key.equals(keyRule.getKey())) {
                return keyRule;
            }
            if ((keyRule.isPrefix() && key.startsWith(keyRule.getKey()))) {
                prefix = keyRule;
            }
            if ("*".equals(keyRule.getKey())) {
                common = keyRule;
            }
        }
        if (prefix != null) {
            return prefix;
        }
        return common;
    }

那么KEY_RULES的规则是怎么来的呢?这就要说到etcd了,其实可以把etcd当做zookeeper,也有对配置crud,然后通知客户端的功能。这里是做了定时拉取+监听变化的双重保证,这里跟携程apollo的处理非常像:不要把鸡蛋放在一个篮子,兜底功能真的很重要。每5秒定时从etcd拉取规则,开启监听器有变化就去etcd拉取规则。fetchRuleFromEtcd从ectd的rule_path获取rules,然后转化成ruleList继续调用notifyRuleChange进行本地处理。

 private void fetchRule() {
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
        //开启拉取etcd的worker信息,如果拉取失败,则定时继续拉取
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            JdLogger.info(getClass(), "trying to connect to etcd and fetch rule info");
            boolean success = fetchRuleFromEtcd();
            if (success) {
                //拉取已存在的热key
                fetchExistHotKey();

                scheduledExecutorService.shutdown();
            }

        }, 0, 5, TimeUnit.SECONDS);
    }

    private boolean fetchRuleFromEtcd() {
        IConfigCenter configCenter = EtcdConfigFactory.configCenter();
        try {
            List<KeyRule> ruleList = new ArrayList<>();
            //从etcd获取自己的rule
            String rules = configCenter.get(ConfigConstant.rulePath + Context.APP_NAME);
            if (StringUtil.isNullOrEmpty(rules)) {
                JdLogger.warn(getClass(), "rule is empty");
                //会清空本地缓存队列
                notifyRuleChange(ruleList);
                return true;
            }
            ruleList = FastJsonUtils.toList(rules, KeyRule.class);

            notifyRuleChange(ruleList);
            return true;
        } catch (StatusRuntimeException ex) {
            //etcd连不上
            JdLogger.error(getClass(), "etcd connected fail. Check the etcd address!!!");
            return false;
        } catch (Exception e) {
            JdLogger.error(getClass(), "fetch rule failure, please check the rule info in etcd");
            return true;
        }

    }

    /**
     * 异步监听rule规则变化
     */
    private void startWatchRule() {
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        executorService.submit(() -> {
            JdLogger.info(getClass(), "--- begin watch rule change ----");
            try {
                IConfigCenter configCenter = EtcdConfigFactory.configCenter();
                KvClient.WatchIterator watchIterator = configCenter.watch(ConfigConstant.rulePath + Context.APP_NAME);
                //如果有新事件,即rule的变更,就重新拉取所有的信息
                while (watchIterator.hasNext()) {
                    //这句必须写,next会让他卡住,除非真的有新rule变更
                    WatchUpdate watchUpdate = watchIterator.next();
                    List<Event> eventList = watchUpdate.getEvents();
                    JdLogger.info(getClass(), "rules info changed. begin to fetch new infos. rule change is " + eventList);

                    //全量拉取rule信息
                    fetchRuleFromEtcd();
                }
            } catch (Exception e) {
                JdLogger.error(getClass(), "watch err");
            }


        });
    }

notifyRuleChange会往EventBus发送KeyRuleInfoChangeEvent的通知,进而进入KeyRuleHolder的putRules方法,这里可以看到维护了KEY_RULES和RULE_CACHE_MAP。

    private void notifyRuleChange(List<KeyRule> rules) {
        EventBusCenter.getInstance().post(new KeyRuleInfoChangeEvent(rules));
    }

    @Subscribe
    public void ruleChange(KeyRuleInfoChangeEvent event) {
        JdLogger.info(getClass(), "new rules info is :" + event.getKeyRules());
        List<KeyRule> ruleList = event.getKeyRules();
        if (ruleList == null) {
            return;
        }

        putRules(ruleList);
    }

  /**
     * 所有的规则,如果规则的超时时间变化了,会重建caffeine
     */
    public static void putRules(List<KeyRule> keyRules) {
        synchronized (KEY_RULES) {
            //如果规则为空,清空规则表
            if (CollectionUtil.isEmpty(keyRules)) {
                KEY_RULES.clear();
                RULE_CACHE_MAP.clear();
                return;
            }

            KEY_RULES.clear();
            KEY_RULES.addAll(keyRules);

            Set<Integer> durationSet = keyRules.stream().map(KeyRule::getDuration).collect(Collectors.toSet());
            for (Integer duration : RULE_CACHE_MAP.keySet()) {
                //先清除掉那些在RULE_CACHE_MAP里存的,但是rule里已没有的
                if (!durationSet.contains(duration)) {
                    RULE_CACHE_MAP.remove(duration);
                }
            }

            //遍历所有的规则
            for (KeyRule keyRule : keyRules) {
                int duration = keyRule.getDuration();
                if (RULE_CACHE_MAP.get(duration) == null) {
                    LocalCache cache = CacheFactory.build(duration);
                    RULE_CACHE_MAP.put(duration, cache);
                }
            }
        }
    }

回到原有流程,getValueSimple方法的链路比较长,主要是通过key的规则,获取到对应的duration,然后从对应duration的本地缓存中获取ValueModel。

    /**
     * 仅获取value,如果不存在也不上报热key
     */
    static ValueModel getValueSimple(String key) {
        Object object = getCache(key).get(key);
        if (object == null) {
            return null;
        }
        return (ValueModel) object;
    }

    private static LocalCache getCache(String key) {
        return CacheFactory.getNonNullCache(key);
    }

    public static LocalCache getNonNullCache(String key) {
        LocalCache localCache = getCache(key);
        if (localCache == null) {
            return DEFAULT_CACHE;
        }
        return localCache;
    }

    public static LocalCache getCache(String key) {
        return KeyRuleHolder.findByKey(key);
    }

    public static LocalCache findByKey(String key) {
        if (StrUtil.isEmpty(key)) {
            return null;
        }
        KeyRule keyRule = findRule(key);
        if (keyRule == null) {
            return null;
        }
        return RULE_CACHE_MAP.get(keyRule.getDuration());
    }

接下来是HotKeyPusher.push,如果是remove则在etcd创建一个节点然后再删除,达到集群删除的效果。如果是探测并且key在规则内,则调用KeyHandlerFactory.getCollector().collect进行统计。

   public static void push(String key, KeyType keyType) {
        push(key, keyType, 1, false);
    }

public static void push(String key, KeyType keyType, int count, boolean remove) {
        if (count <= 0) {
            count = 1;
        }
        if (keyType == null) {
            keyType = KeyType.REDIS_KEY;
        }
        if (key == null) {
            return;
        }
        HotKeyModel hotKeyModel = new HotKeyModel();
        hotKeyModel.setAppName(Context.APP_NAME);
        hotKeyModel.setKeyType(keyType);
        hotKeyModel.setCount(count);
        hotKeyModel.setRemove(remove);
        hotKeyModel.setKey(key);


        if (remove) {
            //如果是删除key,就直接发到etcd去,不用做聚合。但是有点问题现在,这个删除只能删手工添加的key,不能删worker探测出来的
            //因为各个client都在监听手工添加的那个path,没监听自动探测的path。所以如果手工的那个path下,没有该key,那么是删除不了的。
            //删不了,就达不到集群监听删除事件的效果,怎么办呢?可以通过新增的方式,新增一个热key,然后删除它
            EtcdConfigFactory.configCenter().putAndGrant(HotKeyPathTool.keyPath(hotKeyModel), Constant.DEFAULT_DELETE_VALUE, 1);
            EtcdConfigFactory.configCenter().delete(HotKeyPathTool.keyPath(hotKeyModel));
            //也删worker探测的目录
            EtcdConfigFactory.configCenter().delete(HotKeyPathTool.keyRecordPath(hotKeyModel));
        } else {
            //如果key是规则内的要被探测的key,就积累等待传送
            if (KeyRuleHolder.isKeyInRule(key)) {
                //积攒起来,等待每半秒发送一次
                KeyHandlerFactory.getCollector().collect(hotKeyModel);
            }
        }
    }

KeyHandlerFactory.getCollector().collect方法交替使用两个map,对count进行累加,这样清理map的时候就不需要停顿了,交替使用是避免停顿的有效方式。

    @Override
    public void collect(HotKeyModel hotKeyModel) {
        String key = hotKeyModel.getKey();
        if (StrUtil.isEmpty(key)) {
            return;
        }
        if (atomicLong.get() % 2 == 0) {
            //不存在时返回null并将key-value放入,已有相同key时,返回该key对应的value,并且不覆盖
            HotKeyModel model = map0.putIfAbsent(key, hotKeyModel);
            if (model != null) {
                model.setCount(model.getCount() + hotKeyModel.getCount());
            }
        } else {
            HotKeyModel model = map1.putIfAbsent(key, hotKeyModel);
            if (model != null) {
                model.setCount(model.getCount() + hotKeyModel.getCount());
            }
        }
    }

接回上文,还有一个 KeyHandlerFactory.getCounter().collect收集的是规则的访问次数,也是取到对应的规则,然后对规则的访问总数、热次数进行累加。

 @Override
    public void collect(KeyHotModel keyHotModel) {
        if (atomicLong.get() % 2 == 0) {
            put(keyHotModel.getKey(), keyHotModel.isHot(), HIT_MAP_0);
        } else {
            put(keyHotModel.getKey(), keyHotModel.isHot(), HIT_MAP_1);
        }
    }

    public void put(String key, boolean isHot, ConcurrentHashMap<String, HitCount> map) {
        //如key是pin_的前缀,则存储pin_
        String rule = KeyRuleHolder.rule(key);
        //不在规则内的不处理
        if (StrUtil.isEmpty(rule)) {
            return;
        }
        String nowTime = nowTime();

        //rule + 分隔符 + 2020-10-23 21:11:22
        String mapKey = rule + Constant.COUNT_DELIMITER + nowTime;
        //该方法线程安全
        HitCount hitCount = map.computeIfAbsent(mapKey, v -> new HitCount());
        if (isHot) {
            hitCount.hotHitCount.incrementAndGet();
        }
        hitCount.totalHitCount.incrementAndGet();
    }

两个指标的收集已经分析完毕,那怎么发送到worker呢?来到PushSchedulerStarter,这里会启动对两个指标的定时线程池,分别会定时调用NettyKeyPusher的send和sendCount方法。

    /**
     * 每0.5秒推送一次待测key
     */
    public static void startPusher(Long period) {
        if (period == null || period <= 0) {
            period = 500L;
        }
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            IKeyCollector<HotKeyModel, HotKeyModel> collectHK = KeyHandlerFactory.getCollector();
            KeyHandlerFactory.getPusher().send(Context.APP_NAME, collectHK.lockAndGetResult());
            collectHK.finishOnce();
        },0, period, TimeUnit.MILLISECONDS);
    }

    /**
     * 每10秒推送一次数量统计
     */
    public static void startCountPusher(Integer period) {
        if (period == null || period <= 0) {
            period = 10;
        }
        ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            IKeyCollector<KeyHotModel, KeyCountModel> collectHK = KeyHandlerFactory.getCounter();
            KeyHandlerFactory.getPusher().sendCount(Context.APP_NAME, collectHK.lockAndGetResult());
            collectHK.finishOnce();
        },0, period, TimeUnit.SECONDS);
    }

NettyKeyPusher的send和sendCount方法都是为统计数据选择对应的worker然后进行请求,chooseChannel就是根据key哈希到其中一个worker上,然后发送请求即可。

  @Override
    public void send(String appName, List<HotKeyModel> list) {
        //积攒了半秒的key集合,按照hash分发到不同的worker
        long now = System.currentTimeMillis();

        Map<Channel, List<HotKeyModel>> map = new HashMap<>();
        for(HotKeyModel model : list) {
            model.setCreateTime(now);
            Channel channel = WorkerInfoHolder.chooseChannel(model.getKey());
            if (channel == null) {
                continue;
            }

            List<HotKeyModel> newList = map.computeIfAbsent(channel, k -> new ArrayList<>());
            newList.add(model);
        }

        for (Channel channel : map.keySet()) {
            try {
                List<HotKeyModel> batch = map.get(channel);
                channel.writeAndFlush(MsgBuilder.buildByteBuf(new HotKeyMsg(MessageType.REQUEST_NEW_KEY, FastJsonUtils.convertObjectToJSON(batch)))).sync();
            } catch (Exception e) {
                try {
                    InetSocketAddress insocket = (InetSocketAddress) channel.remoteAddress();
                    JdLogger.error(getClass(),"flush error " + insocket.getAddress().getHostAddress());
                } catch (Exception ex) {
                    JdLogger.error(getClass(),"flush error");
                }
            }
        }
    }

    @Override
    public void sendCount(String appName, List<KeyCountModel> list) {
        //积攒了10秒的数量,按照hash分发到不同的worker
        long now = System.currentTimeMillis();
        Map<Channel, List<KeyCountModel>> map = new HashMap<>();
        for(KeyCountModel model : list) {
            model.setCreateTime(now);
            Channel channel = WorkerInfoHolder.chooseChannel(model.getRuleKey());
            if (channel == null) {
                continue;
            }

            List<KeyCountModel> newList = map.computeIfAbsent(channel, k -> new ArrayList<>());
            newList.add(model);
        }

        for (Channel channel : map.keySet()) {
            try {
                List<KeyCountModel> batch = map.get(channel);
                channel.writeAndFlush(MsgBuilder.buildByteBuf(new HotKeyMsg(Context.APP_NAME,
                        MessageType.REQUEST_HIT_COUNT, FastJsonUtils.convertObjectToJSON(batch)))).sync();
            } catch (Exception e) {
                try {
                    InetSocketAddress insocket = (InetSocketAddress) channel.remoteAddress();
                    JdLogger.error(getClass(),"flush error " + insocket.getAddress().getHostAddress());
                } catch (Exception ex) {
                    JdLogger.error(getClass(),"flush error");
                }
            }
        }
    }

    public static Channel chooseChannel(String key) {
        if (StrUtil.isEmpty(key) || WORKER_HOLDER.size() == 0) {
            return null;
        }
        int index = Math.abs(key.hashCode() % WORKER_HOLDER.size());

        return WORKER_HOLDER.get(index).channel;
    }

最后当worker统计到hotkey时,client需要接收worker推送过来的hotkey进行存储,可以看到NettyClientHandler会向EventBus发送ReceiveNewKeyEvent事件,ReceiveNewKeyListener收到此事件后将调用receiveNewKeyListener.newKey,将hotkey放到本地缓存,client端的处理流程就结束了。

  @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, String message) {
        HotKeyMsg msg = FastJsonUtils.toBean(message, HotKeyMsg.class);
        if (MessageType.PONG == msg.getMessageType()) {
            JdLogger.info(getClass(), "heart beat");
            return;
        }
        if (MessageType.RESPONSE_NEW_KEY == msg.getMessageType()) {
            JdLogger.info(getClass(), "receive new key : " + msg);
            HotKeyModel model = FastJsonUtils.toBean(msg.getBody(), HotKeyModel.class);
            EventBusCenter.getInstance().post(new ReceiveNewKeyEvent(model));
        }
    }

    @Subscribe
    public void newKeyComing(ReceiveNewKeyEvent event) {
        HotKeyModel hotKeyModel = event.getModel();
        if (hotKeyModel == null) {
            return;
        }
        //收到新key推送
        if (receiveNewKeyListener != null) {
            receiveNewKeyListener.newKey(hotKeyModel);
        }
    }

  @Override
    public void newKey(HotKeyModel hotKeyModel) {
        long now = System.currentTimeMillis();
        //如果key到达时已经过去1秒了,记录一下。手工删除key时,没有CreateTime
        if (hotKeyModel.getCreateTime() != 0 && Math.abs(now - hotKeyModel.getCreateTime()) > 1000) {
            JdLogger.warn(getClass(), "the key comes too late : " + hotKeyModel.getKey() + " now " +
                    +now + " keyCreateAt " + hotKeyModel.getCreateTime());
        }
        if (hotKeyModel.isRemove()) {
            //如果是删除事件,就直接删除
            deleteKey(hotKeyModel.getKey());
            return;
        }
        //已经是热key了,又推过来同样的热key,做个日志记录,并刷新一下
        if (JdHotKeyStore.isHot(hotKeyModel.getKey())) {
            JdLogger.warn(getClass(), "receive repeat hot key :" + hotKeyModel.getKey() + " at " + now);
        }
        addKey(hotKeyModel.getKey());
    }

    private void addKey(String key) {
        ValueModel valueModel = ValueModel.defaultValue(key);
        if (valueModel == null) {
            //不符合任何规则
            deleteKey(key);
            return;
        }
        //如果原来该key已经存在了,那么value就被重置,过期时间也会被重置。如果原来不存在,就新增的热key
        JdHotKeyStore.setValueDirectly(key, valueModel);
    }


    private void deleteKey(String key) {
        CacheFactory.getNonNullCache(key).delete(key);
    }

  static void setValueDirectly(String key, Object value) {
        getCache(key).set(key, value);
    }

worker端

由上文可知,client与worker的交互只有推送统计数据到worker,worker接收处理,最后推送hotkey到client。因此worker端只需要分析两个部分:统计数据汇总、推送hotkey。
首先看到HotKey的处理逻辑是在HotKeyFilter中,首先会对totalReceiveKeyCount进行累加,然后调用publishMsg,如果统计信息超时1秒或者在白名单中就不处理,否则继续调用keyProducer.push。

 @Override
    public boolean chain(HotKeyMsg message, ChannelHandlerContext ctx) {
        if (MessageType.REQUEST_NEW_KEY == message.getMessageType()) {
            totalReceiveKeyCount.incrementAndGet();
            publishMsg(message.getBody(), ctx);
            return false;
        }

        return true;
    }

    private void publishMsg(String message, ChannelHandlerContext ctx) {
        //老版的用的单个HotKeyModel,新版用的数组
        List<HotKeyModel> models = FastJsonUtils.toList(message, HotKeyModel.class);
        long now = SystemClock.now();
        for (HotKeyModel model : models) {
            //白名单key不处理
            if (WhiteListHolder.contains(model.getKey())) {
                continue;
            }
            long timeOut = now - model.getCreateTime();
            if (timeOut > 1000) {
                logger.info("key timeout " + timeOut + ", from ip : " + NettyIpUtil.clientIp(ctx));
            }
            keyProducer.push(model, now);
        }
    }

keyProducer.push将未过时的统计信息丢进queue中。

    public void push(HotKeyModel model, long now) {
        if (model == null || model.getKey() == null) {
            return;
        }
        //5秒前的过时消息就不处理了
        if (now - model.getCreateTime() > InitConstant.timeOut) {
            expireTotalCount.increment();
            return;
        }

        try {
            QUEUE.put(model);
            totalOfferCount.increment();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

worker端会开启指定数量的KeyConsumer,不断消费queue中的统计数据。根据统计数据的类型调用KeyListener的removeKey和newKey。

    @Bean
    public Consumer consumer() {
        int nowCount = CpuNum.workerCount();
        //将实际值赋给static变量
        if (threadCount != 0) {
            nowCount = threadCount;
        } else {
            if (nowCount >= 8) {
                nowCount = nowCount / 2;
            }
        }

        List<KeyConsumer> consumerList = new ArrayList<>();
        for (int i = 0; i < nowCount; i++) {
            KeyConsumer keyConsumer = new KeyConsumer();
            keyConsumer.setKeyListener(iKeyListener);
            consumerList.add(keyConsumer);

            threadPoolExecutor.submit(keyConsumer::beginConsume);
        }
        return new Consumer(consumerList);
    }
}

public void beginConsume() {
        while (true) {
            try {
                HotKeyModel model = QUEUE.take();
                if (model.isRemove()) {
                    iKeyListener.removeKey(model, KeyEventOriginal.CLIENT);
                } else {
                    iKeyListener.newKey(model, KeyEventOriginal.CLIENT);
                }

                //处理完毕,将数量加1
                totalDealCount.increment();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

KeyListener的removeKey和newKey方法对Cache中的滑动窗口SlidingWindow进行删除或者累加,删除或者达到一定访问数就会推送到根据appname选出所有client进行推送。

 @Override
    public void newKey(HotKeyModel hotKeyModel, KeyEventOriginal original) {
        //cache里的key
        String key = buildKey(hotKeyModel);
        //判断是不是刚热不久
        Object o = hotCache.getIfPresent(key);
        if (o != null) {
            return;
        }

        //********** watch here ************//
        //该方法会被InitConstant.threadCount个线程同时调用,存在多线程问题
        //下面的那句addCount是加了锁的,代表给Key累加数量时是原子性的,不会发生多加、少加的情况,到了设定的阈值一定会hot
        //譬如阈值是2,如果多个线程累加,在没hot前,hot的状态肯定是对的,譬如thread1 加1,thread2加1,那么thread2会hot返回true,开启推送
        //但是极端情况下,譬如阈值是10,当前是9,thread1走到这里时,加1,返回true,thread2也走到这里,加1,此时是11,返回true,问题来了
        //该key会走下面的else两次,也就是2次推送。
        //所以出现问题的原因是hotCache.getIfPresent(key)这一句在并发情况下,没return掉,放了两个key+1到addCount这一步时,会有问题
        //测试代码在TestBlockQueue类,直接运行可以看到会同时hot

        //那么该问题用解决吗,NO,不需要解决,1 首先要发生的条件极其苛刻,很难触发,以京东这样高的并发量,线上我也没见过触发连续2次推送同一个key的
        //2 即便触发了,后果也是可以接受的,2次推送而已,毫无影响,客户端无感知。但是如果非要解决,就要对slidingWindow实例加锁了,必然有一些开销

        //所以只要保证key数量不多计算就可以,少计算了没事。因为热key必然频率高,漏计几次没事。但非热key,多计算了,被干成了热key就不对了
        SlidingWindow slidingWindow = checkWindow(hotKeyModel, key);
        //看看hot没
        boolean hot = slidingWindow.addCount(hotKeyModel.getCount());

        if (!hot) {
            //如果没hot,重新put,cache会自动刷新过期时间
            CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).put(key, slidingWindow);
        } else {
            hotCache.put(key, 1);

            //删掉该key
            CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).invalidate(key);

            //开启推送
            hotKeyModel.setCreateTime(SystemClock.now());
            logger.info(NEW_KEY_EVENT + hotKeyModel.getKey());
            //分别推送到各client和etcd
            for (IPusher pusher : iPushers) {
                pusher.push(hotKeyModel);
            }

        }

    }

@Override
    public void removeKey(HotKeyModel hotKeyModel, KeyEventOriginal original) {
        //cache里的key
        String key = buildKey(hotKeyModel);

        hotCache.invalidate(key);
        CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).invalidate(key);

        //推送所有client删除
        hotKeyModel.setCreateTime(SystemClock.now());
        logger.info(DELETE_KEY_EVENT + hotKeyModel.getKey());

        for (IPusher pusher : iPushers) {
            pusher.remove(hotKeyModel);
        }

    }


    /**
     * 生成或返回该key的滑窗
     */
    private SlidingWindow checkWindow(HotKeyModel hotKeyModel, String key) {
        //取该key的滑窗
        return (SlidingWindow) CaffeineCacheHolder.getCache(hotKeyModel.getAppName()).get(key, (Function<String, SlidingWindow>) s -> {
            //是个新key,获取它的规则
            KeyRule keyRule = KeyRuleHolder.getRuleByAppAndKey(hotKeyModel);
            return new SlidingWindow(keyRule.getInterval(), keyRule.getThreshold());
        });
    }

最后总结

京东的hotkey处理是通过计数来动态判断是否为hotkey,然后缓存再本地内存中,做到毫秒级的scale out。那还有没有其他解决方案?下面是我的观点:
1.如果面对一些缓存key很少的场景,比如活动页信息(同时进行的活动页不可能超过1000),完全就可以直接将缓存放在本地内存中,到了刷新时间就从redis拉取最新缓存即可,不需要动态计算hotkey。也就是常见的多级缓存。
2.同样是动态判断hotkey,但会将hotkey迁移到专门的、更多节点、更高性能的hotkey redis集群中,集群中每个节点都有同一个hotkey缓存,这样就可以做到请求的分散,避免流量都流向同一个redis节点,判断是hotkey就去hotkey集群中取,不需要存在本地内存中了,维护起来会比较简单。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容