分类: 编程鸡汤

  • 首次力压 macOS!这次 Linux 杀疯了!!

    快乐分享,Java干货及时送达👇

    文章来:【公众号:量子位】


    2022年是Linux桌面版之年。

    一位来自亚马逊K8s团队的程序员在自己最新的博客上这样写道。

    何出此言?

    原来是根据Stack Overflow 2022年开发者调查结果得出。

    该报告显示,2022年将Linux作为主要操作系统的比例已经达到了40.23%,不仅超过了macOS,还将差距拉到了9%

    要知道,去年这俩还基本持平,差距仅为0.13%。

    而且,这还不算15%的用户选择WSL的情况,即在Windows系统上运行Linux子系统。

    打出生时就为服务器而生的Linux,真的这么火了?

    首次力压macOS

    Stack Overflow今年这份调查一共有7万多人参与。

    操作系统方面,主要分为“个人使用”和“工作使用”,调查大家在这两种情况下最常用的操作系统。

    结果是无论哪种情况,Linux系统都超过了macOS,尤其以个人使用为甚。

    具体来说,在接收到的71503份结果中,有28765位调查者在个人使用方面选择了Linux系统,占比为40.23%;

    有22217位选择了macOS,占比为31.07%。两者差距近10%。

    而在工作使用方面,选择Linux系统的达到了28523位,占比39.89%,和个人使用基本持平;

    选择macOS的则有23578位,占比32.97%,比个人使用要多一些(这是macOS最特别的地方)。但它和Linux的差距仍达到了近7%。

    除此之外,还有15%左右的人无论是在个人使用还是工作场景都会选择微软的WSL(Windows Subsystem for Linux),进一步证明Linux的受欢迎程度。

    而从往年数据来看,Linux的受欢迎程度一直小步攀升,今年是首次与macOS的差距拉开这么多。

    所以,难怪开头的程序员管今年叫“Linux桌面版之年”。

    具体来看,2018-2020年之间,Linux的数据分别为23.2%、25.6%、 26.6%,一直屈居第三位。

    2021年是分水岭,Linux首次以0.13%的微妙差距超过macOS,成为第二名。

    不过在工作场景中,macOS还是更胜一筹(30.04%VS25.17%)

    到了2022年,Linux一下子就在个人和工作两方面都大比分超过了macOS。

    如Stack Overflow官方所说,这证明了开源软件的吸引力

    当然,它和Windows系统的差距还是不少,后者仍然是三大操作系统里的王者。

    而除了操作系统本身,其他调查的数据也显示,Linux在Steam平台的市场份额近来也一直在提升。

    2022年1月,该平台上Linux玩家占比1.06%,而到了11月,这个数字涨到了1.44%,而这主要归功于Steam Deck这款掌机的上市(Windows仍然是统治地位的96.11%)

    就在2022年10月的Akademy 2022会议上,相关人员透露,Steam Deck的出货量已超过100万个,同时还有一大批延期订单在处理。

    Linux真的这么火了吗?

    还是有网友对如上数据提出了质疑。

    这主要是因为Stack Overflow这个调查中,几大操作系统的数据总和加起来不再等于100%

    TA表示,这个结果说明在选择“您最主要的操作系统时”,很多人都不止选了一个。

    这个数据对于主要只将它用于工作/专业场景的人来说,高得令人难以置信;对于经常在日常也使用Linux的开发人员来说,又低得要命。

    很多人仍然不习惯Linux,他们吐槽的理由包括不太友好的用户UI(即使Ubuntu也让他们受不了)、安装麻烦、包管理复杂等等。

    不过,还是有不少人认为Linux确实越来越火了。

    一位网友表示,Linux的数据或许还会再高一些,毕竟有用户可能本身使用Windows或Mac桌面,但却主要通过远程终端或虚拟机在Linux系统上工作。

    另一位网友则称自己在过去五年里,亲身经历Linux在他们的工作环境中从“很奇怪”、“不常见”变成“再正常不过的事儿”

    甚至有几个非技术岗位的朋友也开始考虑是否要在Thinkpad上运行Linux。

    在TA看来,Linux兴起的因素有很多,包括云的兴起、Linux桌面发行版的成熟、Linux是树莓派等产品的默认/唯一选项、开发者软件越来越支持多平台,以及特别是Linux的硬件兼容性越来越好(以Manjaro版本为甚)等。

    当然,还有人就是喜欢Linux的无广告,和定制化的能力。

    转移到Linux系统的人还有很多,比如这位:

    不仅自己基本放弃Mac,还希望自己公司的员工都转移到Linux上。

    只不过,TA称唯一的阻碍因素是还没有为Linux硬件和软件找到一个好的MDM(移动设备管理)解决方案。

    最后有意思的是,有人既无法抵抗Linux的吸引力,也无法放下macOS,于是“私人用Linux,工作用macOS就成了一个很好的妥协”。

    你最常用什么系统?为什么?

    One More Thing

    最后,再来看看2022年的Stack Overflow开发者调查报告还有哪些亮点。

    1、编程语言方面,Rust已连续第七年成为最受喜爱的语言,约87%的开发人员表示他们希望继续使用它。

    同时,它与Python、TypeScript一起成为最想学习的前三大新语言。

    2、2021年,Git还是大家最常用的基础工具,完全碾压其后的Docker、Yarn等。今年Docker已取代Git夺得第一,使用率从55%增长到69%。

    此外,本项调查还显示,相比专业开发人员,正在学习编码的人更有可能使用3D工具来自学3D VR和AR技术:Unity 3D(23%VS8%)和Unreal Engine(9%VS3%)

    3、Docker和Kubernetes分别位列最受喜爱和想要学习的工具第一和第二位。随着Docker的数据从去年的30%增加到今年的37%,可以看出大家想要使用Docker的愿望并没有放缓。


    4、Phoenix取代Svelte成为最受欢迎的Web框架。Angular.js连续三年成为开发者最讨厌的框架,React.js连续五年成为开发者最想学习的框架。

    5、收入最高的语言仍然是Clojure。

    工具方面,Chef开发人员薪水最高,但它也是开发者最恐怖的工具之一。

    数据库系统方面,收入最高的前三是DynamoDB、Couchbase和Cassandra。

    6、喜欢在线学习编程的人数从60%上升到了70%,相比年轻人(18岁以下),45岁以上的受访者喜欢从书本上学习。

    7、62%的受访者每天花费超过30分钟解决问题;25%的人每天花费一个多小时。

    对于一个由50名开发人员组成的团队来说,每周花费在搜索答案/解决方案上的时间总计333-651小时。

    8、85%的开发人员表示,他们的公司支持远程办公。

    完整报告:
    https://survey.stackoverflow.co/2022/#section-most-popular-technologies-operating-system

    参考链接:
    [1]
    https://www.justingarrison.com/blog/year-of-linux-desktop/
    [2]https://survey.stackoverflow.co/2022/#section-most-popular-technologies-operating-system

  • 某团面试题:JVM 堆内存溢出后,其他线程是否可继续工作?

    快乐分享,Java干货及时送达👇

    文章来源:http://t.csdn.cn/g8GN1


    最近网上出现一个美团面试题:“一个线程OOM后,其他线程还能运行吗?”。

    我看网上出现了很多不靠谱的答案。

    这道题其实很有难度,涉及的知识点有jvm内存分配、作用域、gc等,不是简单的是与否的问题。

    由于题目中给出的OOM,java中OOM又分很多类型;比如:

    • 堆溢出(“java.lang.OutOfMemoryError: Java heap space”)

    • 永久带溢出(“java.lang.OutOfMemoryError:Permgen space”)

    • 不能创建线程(“java.lang.OutOfMemoryError:Unable to create new native thread”)

    等很多种情况。

    本文主要是分析堆溢出对应用带来的影响。

    先说一下答案,答案是还能运行 。

    代码如下:

    public class JvmThread {


        public static void main(String[] args) {
            new Thread(() -> {
                Listbyte[]> list = new ArrayListbyte[]>();
                while (true) {
                    System.out.println(new Date().toString() + Thread.currentThread() + "==");
                    byte[] b = new byte[1024 * 1024 * 1];
                    list.add(b);
                    try {
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }).start();

            // 线程二
            new Thread(() -> {
                while (true) {
                    System.out.println(new Date().toString() + Thread.currentThread() + "==");
                    try {
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

    结果展示:

    Wed Nov 07 14:42:18 CST 2018Thread[Thread-1,5,main]==
    Wed Nov 07 14:42:18 CST 2018Thread[Thread-0,5,main]==
    Wed Nov 07 14:42:19 CST 2018Thread[Thread-1,5,main]==
    Wed Nov 07 14:42:19 CST 2018Thread[Thread-0,5,main]==
    Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
     at com.gosaint.util.JvmThread.lambda$main$0(JvmThread.java:21)
     at com.gosaint.util.JvmThread$$Lambda$1/521645586.run(Unknown Source)
     at java.lang.Thread.run(Thread.java:748)
    Wed Nov 07 14:42:20 CST 2018Thread[Thread-1,5,main]==
    Wed Nov 07 14:42:21 CST 2018Thread[Thread-1,5,main]==
    Wed Nov 07 14:42:22 CST 2018Thread[Thread-1,5,main]==

    JVM启动参数设置:

    上图是JVM堆空间的变化。我们仔细观察一下在14:42:05~14:42:25之间曲线变化,你会发现使用堆的数量,突然间急剧下滑!

    这代表这一点,当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行!

    讲到这里大家应该懂了,此题的答案为:一个线程溢出后,进程里的其他线程还能照常运行

    注意了,这个例子我只演示了堆溢出的情况。如果是栈溢出,结论也是一样的,大家可自行通过代码测试。

    总结:

    其实发生OOM的线程一般情况下会死亡,也就是会被终结掉,该线程持有的对象占用的heap都会被gc了,释放内存。因为发生OOM之前要进行gc,就算其他线程能够正常工作,也会因为频繁gc产生较大的影响。

  • 一次由热部署导致的 OOM 排查经历 !

    快乐分享,Java干货及时送达👇

    来源:juejin.cn/post/7079761581546373127

    • 查看 JVM 的内存使用情况
    • 分析 MetaSpace OOM 原因
    • 快速止血
    • 代码分析
    • 总结

    开发中,将代码部署到 test 环境测试的时候,容器经常因为 OOM 重启,而相同的代部署在 prod 环境则没有问题,怀疑是因为近期 test 环境更换了热部署基础镜像包导致的(由于我们的服务只能在 test 环境进行测试,因此使用了公司的代码修改热部署插件)。于是想通过一些 JVM 工具排查下 OOM 原因,最终发现了历史代码中存在的 bug,同时新的热部署基础镜像包放大了这种影响,导致 metaspace 内存泄漏,排查过程如下:

    查看 JVM 的内存使用情况

    观察JVM的内存使用情况,主要用了 Arthasdashboard 命令和jdk自带的 jstat 工具。首先,查看 arthasdashboard 数据面板的 Memory 区域,发现 metaspace 区内存使用率一致在增加,直到超过 MaxMetaspaceSize 设置的值,发生 metaspace 的OOM。

    观察JVM的内存使用情况

    进一步,通过jdk的 jstat 工具查看gc情况,上一次gc是因为 metaspace 内存占用超过了gc 阈值,同时 metaspace 的使用率一直在90%以上,更加验证了 metaspace OOM:

    观察JVM的内存使用情况

    分析 MetaSpace OOM 原因

    JDK8 之后,metaspace 对应于java运行时数据区的 方法区 ,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,采用本地内存(Native Memory)来实现 方法区 ,本身没有大小限制(受物理内存大小限制),但可以使用 -XX:MaxMetaspaceSize 参数设置上限,我们这边设置的是2G。

    既然是因为类的元数据信息撑爆了元空间,那就看一下类的装载和卸载情况吧,使用 jstat 工具看下类加载情况,并对比 testprod 两个环境的差异:

    「test」

    分析 MetaSpace OOM 原因

    「prod」

    分析 MetaSpace OOM 原因

    prod 环境的服务加载了很多类也卸载了很多类(这其实是个线上问题,通过这次排查才发现的,后面分析原因),而 test 更加严重,加载了很多类,但几乎没有卸载过类。

    重点看下 test 服务的类加载情况,使用 Arthasclassloader 命令:

    分析 MetaSpace OOM 原因

    第一眼就觉得 AviatorClassLoader 比较可疑,其他加载器都是spring和jdk的,这个是谷歌的,同时这个类加载器的实例数量和加载的类的数量非常大,同时随着服务的运行在不断的增长。仅仅是类加载器的实例数量大倒还好,毕竟它的 Class 对象就一份,不会撑爆元空间,但它加载的 Class 会有问题,因为判断是不是同一个类,是由 「加载它的类加载器+全限定类名」 一起决定,于是乎,可以从这个类加载器入手,代码中全局搜索一下,果然找到了引入点。

    快速止血

    我们的项目是接手的一个报表类的服务,项目中用到了一个表达式计算引擎 AviatorEvaluator ,用来根据表达式字符串计算复合指标,其伪代码如下表示:

    public static synchronized Object process(Map eleMap, String expression) {
        // 表达式引擎实例
        AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();
        // 生产表达式对象
        Expression compiledExp = instance.compile(expression, true);
        // 计算表达式结果
        return compiledExp.execute(eleMap);
    }

    其中 expression 是表达式字符串,eleMap 是表达式中变量名和具体值的键值对,比如 expression 对应 a+b ,eleMap对应的键值对为 {"a":10, "b":20} ,则process方法的结果为30

    之前从未仔细看过这段代码,现在看下来似乎有点不太对劲:synchronizednewInstance 是重复了吗?synchronized 是用来做同步的,说明这段代码中有共享资源竞争的情况,应该就是 AviatorEvaluator 实例了,目前的逻辑每次执行 process 都会实例化一个 AviatorEvaluator 对象,这已经不仅仅是线程私有了,而是每一个线程每次调用这个方法都会实例化一个对象,已经属于 「线程封闭」 的场景了,不需要用 synchronized 关键字做同步。我们的业务场景对这个方法的调用量非常大,每个指标的计算都会调用这个方法。至此,有以下结论:

    1. synchronized 同步和线程封闭每个线程私有一个对象二者选其一就行;
    2. 如果 AviatorEvaluator 是线程安全的话,使用单例模式就行,可以减轻堆区的内存压力;

    谷歌出品的工具类不是线程安全的?不会吧?

    查阅资料得知,AviatorEvaluatorexecute 方法是线程安全的,代码里的使用姿势不对,修改代码如下,重新发布,不再OOM了。

    // 删除 synchronized
    public static Object process(Map eleMap, String expression) {
       AviatorEvaluator.execute(expression, eleMap, true); // true 表示使用缓存
    }

    代码分析

    但是,还是有两点难以解释:a) 为什么是 metaspace ?b) 为什么使用热部署镜像的 test 环境出现 OOM,而 prod 没有出现 OOM?

    如果是因为 AviatorEvaluator 对象太多导致的,那也应该是堆区 OOM;同时,prod 环境的请求量远大于 test 环境,如果是像目前 test 这种 metaspace 的膨胀速度,线上肯定也是会 OOM 的,差异在于 test 用的热部署的基础镜像包。

    「首先探寻第一个问题的答案,为什么是 metaspace ?」

    热部署?ClassLoader? 方法区?此时,脑海里回想起一道经典八股文:动态代理的两种方式?(JDK动态代理和CGLIB动态代理的区别?AOP的实现原理?运行期、类加载期的字节码增强?)

    感觉这次 OOM 很大可能是 ** 「ClassLoader」 和 ** 「热部署(字节码增强)」** 撞出的火花 …**

    通过阅读 AviatorEvaluator 的源码,发现调用链简化后大概是这样:

    public Object execute(final String expression, final Map env, final boolean cached) {
      Expression compiledExpression = compile(expression, expression, cached); // 编译生成 Expression 对象
      return compiledExpression.execute(env);  // 执行表达式,输出结果
    }
    private Expression compile(final String cacheKey, final String exp, final String source, final boolean cached) {
     return innerCompile(expression, sourceFile, cached);  // 编译生成 Expression 对象
    }
    private Expression innerCompile(final String expression, final String sourceFile, final boolean cached) {
      ExpressionLexer lexer = new ExpressionLexer(this, expression);
      CodeGenerator codeGenerator = newCodeGenerator(sourceFile, cached); //!!这个方法 new AviatorClassLoader 的实例
      return new ExpressionParser(this, lexer, codeGenerator).parse(); 
    }
      public CodeGenerator newCodeGenerator(final String sourceFile, final boolean cached) {
        // 每个 AviatorEvaluatorInstance 一个 AviatorClassLoader 的实例作为成员变量
        AviatorClassLoader classLoader = this.aviatorClassLoader;
        //!!这个方法通过上面的类加载器不断生成并加载新的Class对象
        return newCodeGenerator(classLoader, sourceFile);
      }
    public CodeGenerator newCodeGenerator(final AviatorClassLoader classLoader, final String sourceFile) {
     ASMCodeGenerator asmCodeGenerator = 
        // 使用字节码工具ASM生成内部类
        new ASMCodeGenerator(this, sourceFile, classLoader, this.traceOutputStream);
    }
    public ASMCodeGenerator(final AviatorEvaluatorInstance instance, final String sourceFile,
        final AviatorClassLoader classLoader, final OutputStream traceOut) {
      // 生成了唯一的内部类
      this.className = "Script_" + System.currentTimeMillis() + "_" + CLASS_COUNTER.getAndIncrement();
    }

    这时候原因差不多清晰了,使用 AviatorEvaluatorInstance 对象计算表达式会使用成员变量里的一个 AviatorClassLoader 加载自定义的字节码生成 CodeGenerator 对象。AviatorEvaluatorInstance 用单例模式使用固然没什么问题,但是如果每次都 new 一个AviatorEvaluatorInstance 对象的话,就会有成百上千的 AviatorClassLoader 对象,这也解释了上面通过 Arthas 查看 classloader 会有这么多对应的实例,但 metaspceClass 还是只有一份的,问题不大。同时看到生成的字节码对象都是 Script_ 开头的,使用 arthassc 命令看下满足条件的类(数量非常多,只截取了部分),果然找到了 OOM 的元凶:

    OOM 的元凶

    「第二个疑问:为什么 prod 没有OOM?」

    上面通过 jstat 发现 prod 卸载了很多类,而 test 几乎不卸载类,两个环境唯一的区别是 test 用了热部署的基础镜像。

    咨询了负责热部署的同事,了解到热部署 agent 会对 classloader 有一些 强引用,监听 classloader 的加载的一些类来监听热更新,这会导致内存泄漏的问题。同时得到反馈,热部署后面会用 弱引用 优化。这里引用下《深入理解java虚拟机》中的解释:

    弱引用优化

    因为大量的 AviatorEvaluatorInstance 创建了大量的 AviatorClassLoader ,并被热部署 agent 强引用了,得不到回收,那么这些类加载器加载的 Script_* 的Class对象也得不到卸载,直到 metaspace OOM。

    通过 JVM 参数 -Xlog:class+load=info-Xlog:class+unload=info 看下 prod 环境的类加载和类卸载日志,确实有大量 Script_* 类的加载和卸载:

    类的加载和卸载

    类的加载和卸载

    test 环境这没有此种类的卸载,这边就不再贴图了。

    此刻,我比较好奇的是热部署包里的什么类型的对象强引用了我们的自定义类加载器?使用 jprofiler 看下两个环境的堆转储文件,不看不知道,一看吓一跳,问题比想象的更加严重:

    「prod」

    堆转储文件

    「test」

    堆转储文件

    对比 prodtest 环境的引用情况,test 环境存在热更新类 WatchHandler 对象到 AviatorClassLoader 的强引用,而 prod 环境不存在;进一步,选择一个具体的 AviatorClassLoader 实例看下引用情况,又有了重大发现:

    「prod」

    AviatorClassLoader

    「test」

    AviatorClassLoader

    prod 环境的 AviatorClassLoader 除了加载我们业务需要的自定义类 Script_*,还加载了很多热更新相关的类,同时不同 AviatorClassLoader 实例加载的热更新相关的类的 hashcode 也是不同了,说明每个 AviatorClassLoader 实例都加载了一轮,这才是元空间占用内存的大头。

    当然,在正确使用 AviatorEvaluator 的情况下(使用单例模式),就不会出现这么严重的问题了,但依然存在热部署 agent 对自定义 classloader 的强引用问题。

    总结

    这是我第一次系统地排查 OOM 问题,把之前一些零散模糊的知识点给串起来的,下面总结了一些本次排查涉及到的 JVM 基础概念和工具:

    「元空间:」

    • https://segmentfault.com/a/1190000012577387

    「类加载器:」

    • https://segmentfault.com/a/1190000037574626
    • https://segmentfault.com/a/1190000023666707

    「JDK工具:」

    • jps:JVM Process Status Tool 进程状况工具
    • jstat:JVM Statistics M onitoring Tool 统计信息监视工具
    • jinfo:Configuration Info for Java Java配置信息工具
    • jmap:Memory Map for Java 内存映像工具
    • jhat:JVM Heap Analysis Tool 堆转储快照分析工具
    • jstack:Stack Trace for Java 堆栈跟踪工具
    • Jcmd:多功能诊断命令行工具
    
    

  • “12306” 是如何支撑百万 QPS 的?

    快乐分享,Java干货及时送达👇

    文章来源:https://juejin.cn/post/6844903949632274445


    目录

    • 12306抢票,极限并发带来的思考?
    • 1. 大型高并发系统架构
    • 2.秒杀抢购系统选型
    • 3. 扣库存的艺术
    • 4. 代码演示
    • 5.总结回顾



    12306抢票,极限并发带来的思考?





    每到节假日期间,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!虽然现在大多数情况下都能订到票,但是放票瞬间即无票的场景,相信大家都深有体会。尤其是春节期间,大家不仅使用12306,还会考虑“智行”和其他的抢票软件,全国上下几亿人在这段时间都在抢票。

    “12306服务”承受着这个世界上任何秒杀系统都无法超越的QPS,上百万的并发再正常不过了!笔者专门研究了一下“12306”的服务端架构,学习到了其系统设计上很多亮点,在这里和大家分享一下并模拟一个例子:如何在100万人同时抢1万张火车票时,系统提供正常、稳定的服务。


    github代码地址:https://github.com/GuoZhaoran/spikeSystem




    1. 大型高并发系统架构





    高并发的系统架构都会采用分布式集群部署,服务上层有着层层负载均衡,并提供各种容灾手段(双火机房、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。下边是一个简单的示意图:


    1.1 负载均衡简介

    上图中描述了用户请求到服务器经历了三层的负载均衡,下边分别简单介绍一下这三种负载均衡:

    (1)OSPF(开放式最短链路优先)是一个内部网关协议(Interior Gateway Protocol,简称IGP)。OSPF通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF会自动计算路由接口上的Cost值,但也可以通过手工指定该接口的Cost值,手工指定的优先于自动计算的值。OSPF计算的Cost,同样是和接口带宽成反比,带宽越高,Cost值越小。到达目标相同Cost值的路径,可以执行负载均衡,最多6条链路同时执行负载均衡。

    (2)LVS (Linux VirtualServer),它是一种集群(Cluster)技术,采用IP负载均衡技术和基于内容请求分发技术。调度器具有很好的吞吐率,将请求均衡地转移到不同的服务器上执行,且调度器自动屏蔽掉服务器的故障,从而将一组服务器构成一个高性能的、高可用的虚拟服务器。

    (3)Nginx想必大家都很熟悉了,是一款非常高性能的http代理/反向代理服务器,服务开发中也经常使用它来做负载均衡。Nginx实现负载均衡的方式主要有三种:轮询、加权轮询、ip hash轮询,下面我们就针对Nginx的加权轮询做专门的配置和测试

    1.2 Nginx加权轮询的演示

    Nginx实现负载均衡通过upstream模块实现,其中加权轮询的配置是可以给相关的服务加上一个权重值,配置的时候可能根据服务器的性能、负载能力设置相应的负载。下面是一个加权轮询负载的配置,我将在本地的监听3001-3004端口,分别配置1,2,3,4的权重:

    #配置负载均衡
        upstream load_rule {
           server 127.0.0.1:3001 weight=1;
           server 127.0.0.1:3002 weight=2;
           server 127.0.0.1:3003 weight=3;
           server 127.0.0.1:3004 weight=4;
        }
        ...
        server {
        listen       80;
        server_name  load_balance.com www.load_balance.com;
        location / {
           proxy_pass http://load_rule;
        }
    }

    我在本地/etc/hosts目录下配置了 www.load_balance.com的虚拟域名地址,接下来使用Go语言开启四个http端口监听服务,下面是监听在3001端口的Go程序,其他几个只需要修改端口即可:

    package main

    import (
     "net/http"
     "os"
     "strings"
    )


    func main() 
    {
     http.HandleFunc("/buy/ticket", handleReq)
     http.ListenAndServe(":3001", nil)
    }

    //处理请求函数,根据请求将响应结果信息写入日志
    func handleReq(w http.ResponseWriter, r *http.Request) {
     failedMsg :=  "handle in port:"
     writeLog(failedMsg, "./stat.log")
    }

    //写入日志
    func writeLog(msg string, logPath string) {
     fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
     defer fd.Close()
     content := strings.Join([]string{msg, "rn"}, "3001")
     buf := []byte(content)
     fd.Write(buf)
    }

    我将请求的端口日志信息写到了./stat.log文件当中,然后使用ab压测工具做压测:

    ab -n 1000 -c 100 http://www.load_balance.com/buy/ticket

    统计日志中的结果,3001-3004端口分别得到了100、200、300、400的请求量,这和我在nginx中配置的权重占比很好的吻合在了一起,并且负载后的流量非常的均匀、随机。



    2.秒杀抢购系统选型





    回到我们最初提到的问题中来:火车票秒杀系统如何在高并发情况下提供正常、稳定的服务呢?


    从上面的介绍我们知道用户秒杀流量通过层层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机所承受的QPS也是非常高的。如何将单机性能优化到极致呢?要解决这个问题,我们就要想明白一件事:通常订票系统要处理生成订单、减扣库存、用户支付这三个基本的阶段,我们系统要做的事情是要保证火车票订单不超卖、不少卖 ,每张售卖的车票都必须支付才有效,还要保证系统承受极高的并发。这三个阶段的先后顺序改怎么分配才更加合理呢?我们来分析一下:

    2.1 下单减库存

    当用户并发请求到达服务端时,首先创建订单,然后扣除库存,等待用户支付。这种顺序是我们一般人首先会想到的解决方案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是一个原子操作。但是这样也会产生一些问题,第一就是在极限并发情况下,任何一个内存操作的细节都至关影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是可想而知的;第二是如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制IP和用户的购买订单数量,这也不算是一个好方法。

    2.2 支付减库存

    如果等待用户支付了订单在减库存,第一感觉就是不会少卖。但是这是并发架构的大忌,因为在极限并发情况下,用户可能会创建很多订单,当库存减为零的时候很多用户发现抢到的订单支付不了了,这也就是所谓的“超卖”。也不能避免并发操作数据库磁盘IO

    2.3 预扣库存

    从上边两种方案的考虑,我们可以得出结论:只要创建订单,就要频繁操作数据库IO。那么有没有一种不需要直接操作数据库IO的方案呢,这就是预扣库存。先扣除了库存,保证不超卖,然后异步生成用户订单,这样响应给用户的速度就会快很多;那么怎么保证不少卖呢?用户拿到了订单,不支付怎么办?

    我们都知道现在订单都有有效期,比如说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在很多网上零售企业保证商品不少卖采用的方案。订单的生成是异步的,一般都会放到MQ、kafka这样的即时消费队列中处理,订单量比较少的情况下,生成订单非常快,用户几乎不用排队。



    3. 扣库存的艺术





    从上面的分析可知,显然预扣库存的方案最合理。我们进一步分析扣库存的细节,这里还有很大的优化空间,库存存在哪里?怎样保证高并发下,正确的扣库存,还能快速的响应用户请求?

    在单机低并发情况下,我们实现扣库存通常是这样的:

    为了保证扣库存和生成订单的原子性,需要采用事务处理,然后取库存判断、减库存,最后提交事务,整个流程有很多IO,对数据库的操作又是阻塞的。这种方式根本不适合高并发的秒杀系统。

    接下来我们对单机扣库存的方案做优化:本地扣库存 。我们把一定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单。改进过之后的单机系统是这样的:

    这样就避免了对数据库频繁的IO操作,只在内存中做运算,极大的提高了单机抗并发的能力。但是百万的用户请求量单机是无论如何也抗不住的,虽然nginx处理网络请求使用epoll模型,c10k的问题在业界早已得到了解决。但是linux系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应。

    上面我们提到了nginx的加权均衡策略,我们不妨假设将100W的用户请求量平均均衡到100台服务器上,这样单机所承受的并发量就小了很多。然后我们每台机器本地库存100张火车票,100台服务器上的总库存还是1万,这样保证了库存订单不超卖,下面是我们描述的集群架构:

    问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这100台服务器上有两三台机器因为扛不住并发的流量或者其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。

    要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存 。有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“buffer库存”用来防止机器中有机器宕机的情况。我们结合下面架构图具体分析一下:

    我们采用Redis存储统一库存,因为Redis的性能非常高,号称单机QPS能抗10W的并发。在本地减库存以后,如果本地有订单,我们再去请求redis远程减库存,本地减库存和远程减库存都成功了,才返回给用户抢票成功的提示,这样也能有效的保证订单不会超卖。

    当机器中有机器宕机时,因为每个机器上有预留的buffer余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不少卖。buffer余票设置多少合适呢,理论上buffer设置的越多,系统容忍宕机的机器数量就越多,但是buffer设置的太大也会对redis造成一定的影响。

    虽然redis内存数据库抗并发能力非常高,请求依然会走一次网络IO,其实抢票过程中对redis的请求次数是本地库存和buffer库存的总量,因为当本地库存不足时,系统直接返回用户“已售罄”的信息提示,就不会再走统一扣库存的逻辑,这在一定程度上也避免了巨大的网络请求量把redis压跨,所以buffer值设置多少,需要架构师对系统的负载能力做认真的考量。



    4. 代码演示





    Go语言原生为并发设计,我采用go语言给大家演示一下单机抢票的具体流程。

    4.1 初始化工作

    go包中的init函数先于main函数执行,在这个阶段主要做一些准备性工作。我们系统需要做的准备工作有:初始化本地库存、初始化远程redis存储统一库存的hash键值、初始化redis连接池;另外还需要初始化一个大小为1的int类型chan,目的是实现分布式锁的功能,也可以直接使用读写锁或者使用redis等其他的方式避免资源竞争,但使用channel更加高效,这就是go语言的哲学:不要通过共享内存来通信,而要通过通信来共享内存 。redis库使用的是redigo,下面是代码实现:

    ...
    //localSpike包结构体定义
    package localSpike

    type LocalSpike struct {
     LocalInStock     int64
     LocalSalesVolume int64
    }
    ...
    //remoteSpike对hash结构的定义和redis连接池
    package remoteSpike
    //远程订单存储健值
    type RemoteSpikeKeys struct {
     SpikeOrderHashKey string //redis中秒杀订单hash结构key
     TotalInventoryKey string //hash结构中总订单库存key
     QuantityOfOrderKey string //hash结构中已有订单数量key
    }

    //初始化redis连接池
    func NewPool() *redis.Pool {
     return &redis.Pool{
      MaxIdle:   10000,
      MaxActive: 12000// max number of connections
      Dial: func() (redis.Conn, error) {
       c, err := redis.Dial("tcp"":6379")
       if err != nil {
        panic(err.Error())
       }
       return c, err
      },
     }
    }
    ...
    func init() {
     localSpike = localSpike2.LocalSpike{
      LocalInStock:     150,
      LocalSalesVolume: 0,
     }
     remoteSpike = remoteSpike2.RemoteSpikeKeys{
      SpikeOrderHashKey:  "ticket_hash_key",
      TotalInventoryKey:  "ticket_total_nums",
      QuantityOfOrderKey: "ticket_sold_nums",
     }
     redisPool = remoteSpike2.NewPool()
     done = make(chan int1)
     done 1
    }

    4.2 本地扣库存和统一扣库存

    本地扣库存逻辑非常简单,用户请求过来,添加销量,然后对比销量是否大于本地库存,返回bool值:

    package localSpike
    //本地扣库存,返回bool值
    func (spike *LocalSpike) LocalDeductionStock() bool{
     spike.LocalSalesVolume = spike.LocalSalesVolume + 1
     return spike.LocalSalesVolume }

    注意这里对共享数据LocalSalesVolume的操作是要使用锁来实现的,但是因为本地扣库存和统一扣库存是一个原子性操作,所以在最上层使用channel来实现,这块后边会讲。统一扣库存操作redis,因为redis是单线程的,而我们要实现从中取数据,写数据并计算一些列步骤,我们要配合lua脚本打包命令,保证操作的原子性:

    package remoteSpike
    ......
    const LuaScript = `
            local ticket_key = KEYS[1]
            local ticket_total_key = ARGV[1]
            local ticket_sold_key = ARGV[2]
            local ticket_total_nums = tonumber(redis.call('HGET', ticket_key, ticket_total_key))
            local ticket_sold_nums = tonumber(redis.call('HGET', ticket_key, ticket_sold_key))
      -- 查看是否还有余票,增加订单数量,返回结果值
           if(ticket_total_nums >= ticket_sold_nums) then
                return redis.call('HINCRBY', ticket_key, ticket_sold_key, 1)
            end
            return 0
    `
    //远端统一扣库存
    func (RemoteSpikeKeys *RemoteSpikeKeys) RemoteDeductionStock(conn redis.Conn) bool {
     lua := redis.NewScript(1, LuaScript)
     result, err := redis.Int(lua.Do(conn, RemoteSpikeKeys.SpikeOrderHashKey, RemoteSpikeKeys.TotalInventoryKey, RemoteSpikeKeys.QuantityOfOrderKey))
     if err != nil {
      return false
     }
     return result != 0
    }

    我们使用hash结构存储总库存和总销量的信息,用户请求过来时,判断总销量是否大于库存,然后返回相关的bool值。在启动服务之前,我们需要初始化redis的初始库存信息:

     hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0

    4.3 响应用户信息

    我们开启一个http服务,监听在一个端口上:

    package main
    ...
    func main() {
     http.HandleFunc("/buy/ticket", handleReq)
     http.ListenAndServe(":3005", nil)
    }

    上面我们做完了所有的初始化工作,接下来handleReq的逻辑非常清晰,判断是否抢票成功,返回给用户信息就可以了。

    package main
    //处理请求函数,根据请求将响应结果信息写入日志
    func handleReq(w http.ResponseWriter, r *http.Request) {
     redisConn := redisPool.Get()
     LogMsg := ""
      //全局读写锁
     if localSpike.LocalDeductionStock() && remoteSpike.RemoteDeductionStock(redisConn) {
      util.RespJson(w, 1,  "抢票成功", nil)
      LogMsg = LogMsg + "result:1,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
     } else {
      util.RespJson(w, -1"已售罄", nil)
      LogMsg = LogMsg + "result:0,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
     }
     done 1

     //将抢票状态写入到log中
     writeLog(LogMsg, "./stat.log")
    }

    func writeLog(msg string, logPath string) {
     fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
     defer fd.Close()
     content := strings.Join([]string{msg, "rn"}, "")
     buf := []byte(content)
     fd.Write(buf)
    }

    前边提到我们扣库存时要考虑竞态条件,我们这里是使用channel避免并发的读写,保证了请求的高效顺序执行。我们将接口的返回信息写入到了./stat.log文件方便做压测统计。

    4.4 单机服务压测

    开启服务,我们使用ab压测工具进行测试:

    ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket

    下面是我本地低配mac的压测信息

    This is ApacheBench, Version 2.3 1826891 $>
    Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
    Licensed to The Apache Software Foundation, http://www.apache.org/

    Benchmarking 127.0.0.1 (be patient)
    Completed 1000 requests
    Completed 2000 requests
    Completed 3000 requests
    Completed 4000 requests
    Completed 5000 requests
    Completed 6000 requests
    Completed 7000 requests
    Completed 8000 requests
    Completed 9000 requests
    Completed 10000 requests
    Finished 10000 requests


    Server Software:
    Server Hostname:        127.0.0.1
    Server Port:            3005

    Document Path:          /buy/ticket
    Document Length:        29 bytes

    Concurrency Level:      100
    Time taken for tests:   2.339 seconds
    Complete requests:      10000
    Failed requests:        0
    Total transferred:      1370000 bytes
    HTML transferred:       290000 bytes
    Requests per second:    4275.96 [#/sec] (mean)
    Time per request:       23.387 [ms] (mean)
    Time per request:       0.234 [ms] (mean, across all concurrent requests)
    Transfer rate:          572.08 [Kbytes/sec] received

    Connection Times (ms)
                  min  mean[+/-sd] median   max
    Connect:        0    8  14.7      6     223
    Processing:     2   15  17.6     11     232
    Waiting:        1   11  13.5      8     225
    Total:          7   23  22.8     18     239

    Percentage of the requests served within a certain time (ms)
      50%     18
      66%     24
      75%     26
      80%     28
      90%     33
      95%     39
      98%     45
      99%     54
     100%    239 (longest request)

    根据指标显示,我单机每秒就能处理4000+的请求,正常服务器都是多核配置,处理1W+的请求根本没有问题。而且查看日志发现整个服务过程中,请求都很正常,流量均匀,redis也很正常:

    //stat.log
    ...
    result:1,localSales:145
    result:1,localSales:146
    result:1,localSales:147
    result:1,localSales:148
    result:1,localSales:149
    result:1,localSales:150
    result:0,localSales:151
    result:0,localSales:152
    result:0,localSales:153
    result:0,localSales:154
    result:0,localSales:156
    ...


    5.总结回顾





    总体来说,秒杀系统是非常复杂的。我们这里只是简单介绍模拟了一下单机如何优化到高性能,集群如何避免单点故障,保证订单不超卖、不少卖的一些策略,完整的订单系统还有订单进度的查看,每台服务器上都有一个任务,定时的从总库存同步余票和库存信息展示给用户,还有用户在订单有效期内不支付,释放订单,补充到库存等等。

    我们实现了高并发抢票的核心逻辑,可以说系统设计的非常的巧妙,巧妙的避开了对DB数据库IO的操作,对Redis网络IO的高并发请求,几乎所有的计算都是在内存中完成的,而且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。我觉得其中有两点特别值得学习总结:

    (1)负载均衡,分而治之。通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致,这样系统的整体也就能承受极高的并发了,就像工作的的一个团队,每个人都将自己的价值发挥到了极致,团队成长自然是很大的。

    (2)合理的使用并发和异步。自epoll网络架构模型解决了c10k问题以来,异步越来被服务端开发人员所接受,能够用异步来做的工作,就用异步来做,在功能拆解上能达到意想不到的效果,这点在nginx、node.js、redis上都能体现,他们处理网络请求使用的epoll模型,用实践告诉了我们单线程依然可以发挥强大的威力。服务器已经进入了多核时代,go语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使用并发来解决,比如go处理http请求时每个请求都会在一个goroutine中执行,总之:怎样合理的压榨CPU,让其发挥出应有的价值,是我们一直需要探索学习的方向。