发现一个开源项目优化点,点进来就是你的了

最近无聊(摸)闲逛(鱼)GitHub 时,发现了一个阿里开源项目可以贡献代码的地方。


不是写单测、改代码格式那种,而是比较有挑战的性能优化,最关键的是还不难,仔细看完本文后,有点基础就能写出来的那种,话不多说,发车!



相信大家在日常写代码获取时间戳时,会写出如下代码:


  • long ts = System.currentTimeMillis();

    读者中还有一些 Gopher,我们用 Go 也写一遍:


  • UnixTimeUnitOffset = uint64(time.Millisecond / time.Nanosecond)ts := uint64(time.Now().UnixNano()) / UnixTimeUnitOffset


    在一般情况下这么写,或者说在 99% 的情况下这么写一点问题都没有。但有位大佬研究了 Java 下时间戳的获取:


    http://pzemtsov.github.io/2017/07/23/the-slow-currenttimemillis.html


    他得出了一个结论:并发越高,获取时间戳越慢!




    具体到细节咱也不是很懂。大概原因是由于只有一个全局时钟源,高并发或频繁访问会造成严重的争用。


    1. 缓存时间戳


    我最早接触到用缓存时间戳的方式来优化是在 Cobar 这个项目中。


    https://github.com/alibaba/cobar


    由于 Cobar 是一款数据库中间件,它的 QPS 可能会非常高,所以才有了这个优化。


    我们瞅一眼它的实现:


  • 起一个单独的线程每隔 20ms 获取一次时间戳并缓存起来;
  • 使用时间戳时直接取缓存。

  • https://github.com/alibaba/cobar/blob/master/server/src/main/server/com/alibaba/cobar/util/TimeUtil.java


  • /** * 弱精度的计时器,考虑性能不使用同步策略。 *  * @author xianmao.hexm 2011-1-18 下午06:10:55 */public class TimeUtil {    private static long CURRENT_TIME = System.currentTimeMillis();
    public static final long currentTimeMillis() { return CURRENT_TIME; }
    public static final void update() { CURRENT_TIME = System.currentTimeMillis(); }}


    https://github.com/alibaba/cobar/blob/master/server/src/main/server/com/alibaba/cobar/CobarServer.java


  • timer.schedule(updateTime(), 0L, TIME_UPDATE_PERIOD); // TIME_UPDATE_PERIOD 是 20ms...// 系统时间定时更新任务private TimerTask updateTime() {    return new TimerTask() {        @Override        public void run() {            TimeUtil.update();        }    };}

    Cobar 之所以这么干,一是因为往往它的 QPS 非常高,这样可以减少获取时间戳的CPU消耗或者耗时。其次,这个时间戳在 Cobar 内部只做统计使用,就算不准确也并无大碍,从实现上看也确实是弱精度。


    后来,我也在其他的代码中看到了类似的实现,比如 Sentinel(不是 Redis 的Sentinel,而是阿里开源的限流熔断利器 Sentinel)。


    Sentinel 作为一款限流熔断的工具,自然是自身的开销越小越好。于是同样都是出自阿里的 Sentinel 也用了和 Cobar 类似的实现缓存时间戳


    原因也很简单,尽可能减少对系统资源的消耗。获取时间戳的性能要更优秀,但又不能和 Cobar 那样搞个弱精度的时间戳。


    因为 Sentinel 获取到的时间戳很可能就决定了一次请求是否被限流、熔断,所以解决办法也很简单,直接将缓存时间戳的间隔改成 1 毫秒。


    下面是 Sentinel 这部分的代码:



    甚至后来的 Sentinel-Go 也采取了一模一样的逻辑:



    以前没有多想,认为这样并没有什么不妥。直到前两天晚上,没事在 Sentinel-Go 社区中瞎逛,看到了一个 issue 大受震撼:


    https://github.com/alibaba/sentinel-golang/issues/441


    提出这位 issue 的大佬在第一段就给出了非常有见解的观点:



    说的比较委婉,什么叫「负向收益」?


    我又搜索了一下,找到了这个 issue:


    https://github.com/alibaba/Sentinel/issues/1702



    TimeUtil 吃掉了 50% 的 CPU,这就是「负向收益」,还是比较震惊的!



    看到这个 issue,我简单地想了下:


  • 耗时 :获取时间戳在一般情况下耗时几乎都不会影响到系统,尤其是我们常写的业务系统;
  • CPU :假设每毫秒缓存一次时间戳,抛开其他开销不说,每秒就有 1000 次获取时间戳的调用。如果每次请求中只有 1 次获取时间戳的操作,那么至少得有 1000 QPS 的请求,才能填平缓存时间戳的开销。况且还有其他开销。

  • 但这只是我们的想当然,如果有数据支撑就又说服力了。为此前面提出「负向收益」的大佬做了一系列分析和测试,我们白嫖一下他的成果:






    看完后我跪在原地,久久不能起身。



    课代表来做个总结:


  • 缓存时间戳开销最大的地方是 sleep 和获取时间戳;
  • 理论上来说,单机 QPS 需要大于 4800 才会有正向收益,真实测试结果也是在 4000 QPS 以内都没有正向收益;
  • 如果不要这个缓存时间戳,获取时间戳耗时会增加,但这在可接受范围内;
  • 鉴于常规情况下 QPS 很少会达到 4000,所以最后结论是,在 Sentinel-Go 中默认禁用这个特性。

  • 这一顿操作下来,连Sentinel社区的大佬也觉得很棒,竖起来大拇指:



    然而做了这么多测试,最后的修改就只是把 true 改成 false。



    2. 自适应算法


    本来我以为看到这位大佬的测试已经是非常有收获了,没想到接下去的闲逛又让我发现了一个更了不得的东西。


    既然上面分析出来,在 QPS 比较高的情况下,收益才能抵消被抵消。那么,有没有可能实现一个自适应的算法,在 QPS 较低的时候直接从系统获取;QPS 较高时,从缓存获取?


    果不其然,Sentinel(Java版,版本>=1.8.2)已经实现了!


    issue 参考:https://github.com/alibaba/Sentinel/pull/1746


    我们捋一下它的实现:



    我们首先看最核心的缓存时间戳的循环(每毫秒执行 1 次)。在这个循环中,它将缓存时间戳分成了三个状态:


  • RUNNING  运行态 :执行缓存时间戳策略,并统计写时间戳的 QPS(把对缓存时间戳的读写 QPS 分开统计);
  • IDLE  空闲态 :初始状态。什么都不做,只休眠 300 毫秒;
  • PREPARE  准备态 :缓存时间戳,但不统计 QPS。

  • 这三个状态怎么流转呢?


    答案在开头调用的 check 方法中:


  • 首先,check 逻辑有个间隔,也就是每隔一段时间(3 秒)来做一次状态转换。

  • 其次,如果当前状态是空闲态并且读 QPS 大于 HITS_UPPER_BOUNDARY(1200),则切换为准备态。

  • 如果当前状态是运行态且读 QPS 小于 HITS_LOWER_BOUNDARY(800),则切换为空闲态。


  • 发现似乎少了切换到运行态的分支。看上面的循环中,第三个准备态的分支运行一次后,就将状态切换为运行态了。


    这是为啥?


    其实,准备态只是为了让程序从空闲态切换到运行态时过渡得更平滑。因为空闲态下缓存时间戳不再更新。如果没有过渡直接切换到运行态,那可能切换后获取的时间戳是有误差的。


    文字可能不直观,我们画一个状态流转图:



    最后这些准备好了,获取时需要做两件事:一是统计读时间戳的 QPS,二是获取时间戳。如果是空闲态或准备态,则直接获取系统时间返回。如果是运行态则从缓存中拿时间戳。



    当程序比较空闲时,不会缓存时间戳,降低 CPU 的消耗。QPS 较高时会缓存时间戳,也能降低 CPU 的消耗,并且能降低获取时间戳的时延。可谓一举两得。


    但这中间我有个疑问:这里 QPS 的高低边界不知道是如何得出的?是拍脑袋得出的,还是压测出来的?


    不过这个数值似乎并不一定绝对准确,可能和机器的配置也有关系。所以我倾向这个值可以配置,而不是在代码中写死。关于这点,这段代码的作者也解释了原因:



    最后可能你会问,这 QPS 咋统计呀?


    这可是 Sentinel 的强项,利用 LeapArray 统计。由于这不是本文重点,这里就不展开了。


    有没有测试数据支撑呢?


    有另一位大佬在评论区贴出了他的测试数据,我们看一下:



    在低负载下,CPU 消耗降低得特别明显,高负载则没什么变化,这也符合我们的预期。


    看到这里你是不是觉得该点题了?


    没错,Sentinel-Go 还没实现上述的自适应算法。这是个绝佳的机会,有技术含量,又有参考(Java版),是不是心动了?


    社区中也有该 issue:


    https://github.com/alibaba/sentinel-golang/issues/419



    这个 issue 在 2021 年 8 月有个哥们认领了,但截止目前还没贡献代码。四舍五入等于他放弃了,所以你懂我意思吧?


    - EOF -

    推荐阅读   点击标题可跳转

    1、面试官:你给我说一下什么是时间轮吧?

    2、深入理解 Java 如何测量时间:从 API 到内核调用

    3、计算机时间到底是怎么来的?程序员必看的时间知识!


    看完本文有收获?请转发分享给更多人

    关注「ImportNew」,提升Java技能

    点赞和在看就是最大的支持❤️