美文网首页
我公司的技术架构演化(三)高并发能力

我公司的技术架构演化(三)高并发能力

作者: 胖虎大哥 | 来源:发表于2021-05-24 10:43 被阅读0次

    有人会问过我,我们是怎么抗住百万日活的?其实这个问题很难回答,因为支持这样的高并发并没有什么难度,如果一定要讲的话,我会把一个链路里的请求进行一下拆解,来分段讲解下思路

    先看这张图:


    image.png
    1. DDOS:有很短的延时、不可优化
      1. 一般早期公司不用做这个,毕竟这个东西的成本是超高的,而且几乎不可自建;如果你被DDOS攻击了,别想节省成本,赶紧上吧。
    2. WAF:较短延时、不可优化
    3. 网关:可优化、收益一般、风险高
      1. 我们的网关是用的java生态的,选型spring cloud gateway,java语言的特性会使得这个网关显得比较笨重,优点是团队都会java,并且和我们的整体架构严丝合缝。

      2. 优化代替方案:apache apisix,一款nginx + etcd组合的网关 https://github.com/apache/apisix,功能十分强大,插件很丰富,但它有两个问题

        1. 身边没有公司有这样的经验,网上的资料不够多
        2. 本身是C语言写的,如果要写具体逻辑,比如风控、验签等,一旦插件不支持需要自己开发,则要学lua脚本,这个团队会的人很少。
        3. image.png
      3. sign、jwt token、黑白名单、风控旁路,这几个基本是没有优化空间的。

    4. BFF(plate)层:可优化,风险低
      1. 要提到BFF层是做什么的,从功能上来说,可以叫它,请求分发器;按功能来说,可以叫它,模块化;我画了个时序图,简单理解下,BFF层就是图中的plate + gourpService。


        image.png
      2. 你看到这个模型会不会想到,为什么你再BFF层不使用NIO技术(tomcat、redis、dubbo),为什么不用响应式编程(node、rxjava),我的回答是,因为简单;我的核心不在于性能,性能我可以堆几台高性能的服务器,一年也就多个小几千块,但我去找一个擅长网络编程的人,来专门做个中间件并维护的价格来说,就太贵了。

    5. 应用程序:重点优化
      1. 应用程序的优化方案要case by case,不能一概而论;一般互联网项目的请求都是CPU密集的,而非IO密集的,所以,一般来说只要程序写的没问题,一般不会对网络带宽有较大的冲击(但实际上我们有一次压测还真碰到了这个问题,压测的时候把redis的带宽给打满了,对redis的利用太粗暴了),优化基本都是在流程和sql里。
      2. 那我给出几个常见的优化方式
        1. 异步处理:我们在一个类里,基本都是串行的模式(为什么不用并行?以为并行不仅要面临线程控制不好,导致OOM线程创建失败的风险;还要合并结果集,代码难度高处一个档次),那我们一般来说最好的方法就是把如果没有上下文强依赖的外部请求(不需要等结果集,比如发送短信,失败重试)变成异步请求;异步的方式有以下几种
          1. 消息队列:这种是最常见的,也是最好用的,对系统没有额外负担,只需要保证消息服务落盘OK,即可马上返回成功。
          2. 扫表&定时任务:数据量小的时候,这类的处理也是比较简单的,一旦数据量变大,给DB带来的压力会很大。
          3. 异步线程调用:这个是我们现在在用的方法,框架是hystrix,它的原理是异步转同步;如果你不懂什么是异步转同步,可以查一下dubbo里的异步转同步,原理是一样的;我在这里简单的提一下什么是异步转同步:
            1. 从主线程创建子线程,来执行远程调用
            2. 主线程通过lock里的condition.await来阻塞,等待子线程返回结果。
            3. 子线程拿到结果集之后,因为是NIO,他并不知道如何找到主线程,那怎么办呢?主线程创建一个Map<requestId,response>,创建子线程的时候,将requestId传递给主线程;这样主线程只需要定期循环map,查询是否有自己的requestId的结果集,一旦发现有,则取出值,则sigal唤醒,返回给上层。
        2. 快速失败 fastover
          1. 这其实是一个思想,它是说如果这个请求的是有可能被一些条件限定拦住的,那应该优先去找到那些限定范围,第一时间判断失败返回。
          2. 举一个例子 一个程序的按照业务逻辑的思路是 A→B→C→D,A\B\C\D本身没有参数依赖,假设如果D中有一个条件,如果满足了条件,则整个处理失败。那我应该把整个逻辑调整为 D→A→B→C,他虽然不符合我们平时思考的逻辑,但只要不影响全局逻辑的处理,先调用哪个微服务,又怎么会出错呢?
        3. 缓存
          1. 缓存是一个非常值得讨论的,只要读的比例比写的比例高很多,那缓存就是一个最好的场景。不过,我给你出一下一个题目,你来思考一下怎么思考缓存的逻辑;“我的订单”这个页面中,要调用用户信息和订单列表,如果你是整个系统的架构师,你要怎么设计呢?有以下几种思路?这道题大伙自己探讨吧,是个非常值得通过场景来深度思考的题


            image.png
            1. 用户信息的接口缓存、订单列表的接口缓存,我的订单的页面接口不缓存。
            2. 我的订单页面接口缓存,另外两个接口不缓存。
            3. 全缓存
          2. 缓存的真正含义,很多人把缓存理解为 redis、memcache,我觉得理解的还是比较片面,实际上,ES、MYSQL里的undo log、hbase里的memtable,我觉得都是缓存,这里涉及了一些概念,你如果不了解的话,建议去百度一下。
          3. 缓存的核心能力:我理解是HA(高可用)和TPS(吞吐量),如果是分布式缓存,要符合BASE理论。redis本身单机能挡住10W并发,redis的HA有哨兵、cluster、还有codis(阿里)、Twenproxy(Twitter)等高可用方案,这里不再深入细节;ES拥有无限扩容的集群能力,以及通过LSMtree协议产生的高并发写和强大的HA能力,这两个都是抗住高并发的利器,尤其是ES,几乎能承接所有来自于用户查询&筛选的能力。
        4. 限流
          1. 限流有很多种业务场景

            1. 上游限流:这是一个标准的熔断场景,比如当A调用B,A担心B的并发能力不够,把B打死,因此A自身做了限流处理。
            2. 下游限流:当A同时调用B\C\D,这里只有B的吞吐量是最差的,但A是没办法了解所有下游系统的能力,并针对下游系统去限制自身的吞吐,听起来就是一个很不合理的需求。所以B就要考虑自己的HA问题了,因为B清晰的知道自己的承载量,所以B可以自身做限流。 image.png
          2. 限流的种类

            1. QPS
            2. 并发线程数
            3. RT时间
          3. 限流的工具

            1. 分布式限流nginx、sentinal,这两种是常用的,尤其是sentinal支持无缝配置生效
            2. 单机限流hystrix,这块spring还提供了一个监控。
          4. 限流的技术

            1. 静态窗口,一般用redis可实现,缺点是两个临界点直接会出现double的流量。
            2. 滑动窗口,hystrix默认的模式、sentinal默认的模式
            3. 漏桶,redis4.0的cell模式的模式
            4. 令牌桶,RateLimter可以配置此模式
        5. 熔断
          1. 熔断及容灾,它的核心的牺牲局部保全整体。比如:在商品详情页,最重要的服务是商品服务和下单服务;而其他的图片服务、评价服务如果出现了异常,是不影响用户购买的主流程的;因此、图片服务和评价服务是可以接受熔断的。在一般电商大促的时候,我们都会对核心的接口做流程分析,将非核心的服务做降级服务,一旦接口性能出现问题,则壮士断腕,直接抛弃部分功能,保全核心能力。
          2. 现在常用的熔断技术就是spring cloud的hystrix。
    6. 数据库
      1. 随着计算机技术的快速发展,我们的CPU、内存性能越来越强,价格也越来越便宜;再加上我们的框架,天然支持服务器的水平扩容,因此,几乎90%以上的性能瓶颈,都是在数据库的处理上。
      2. 关于读的能力瓶颈
        1. 困难&解决方案:虽然读可扩展,一主多从的形式,可以让读性能得到很好的扩容;但是单表的能力一样受到很大的限制,造成这样问题的元凶是因为mysql在设计的时候,采用的就是全量副本的概念,这种定位,就会导致不可能会有很强的水平扩容能力。那如何解决单标数据量太大的问题呢?
          1. 表的长度不适合设置的太大;
            1. 这里要懂得mysql的检索逻辑,mysql是B+树的逻辑,最大高度一般是4以内;一般默认单个B+树的节点大小是16K,这么设置的原因是,mysql自己定义了数据页的概念(其实mysql模仿的linux的pagecache的一些设定),一页大小是16K;所以,单个节点上装的节点数是优先的(可以计算出来,比如索引是BIGINT,那是8个字节,用16K*1024/8就是能装的个数);通过对非叶子节点的寻址,一直找到最后的叶子节点。
            2. 如果找到结果,就需要去回表(通过聚簇索引找到放数据的地址),再将数据加载到内存里返回。这个过程当中,你的一行的数据越大,每个数据页装的就越少,比如一行数据2K,数据页能装8条,用limit 80的时候,需要扫描10个数据页;而如果一行数据只有512字节,一页数据页能装32条,那limit80的查询只需要扫描3个数据页就好了。
            3. 那这里我想进一步挖一下,MYSQL是怎么把这些数据拉出来的,实际上当mysql通过内存寻址去访问具体的磁盘地址的时候,linux会将数据放到buffcache,再装入pageche,mysql再读pagecache将数据加载到自己的内存;这个流程本身也是十分消耗性能,因此,对数据行的大小控制一定要慎重。
          2. 索引不适合建太多;
            1. 一般mysql会优先划分一下buffer pool,这个你可以理解为是一个万能用内存,这个内存里使用LRU进行淘汰的;我们在进行查询的时候,都是先要把索引加载到buffer pool中,如果你的索引足够少,就能在内存中存活的时间很长;但如果你的索引非常多,经常会被淘汰重新加载,那就会浪费很多资源。PS:了解一下系统态→用户态对CPU的消耗逻辑。
          3. mysql内部的patition
            1. 这个常规技术,mysql很早就有了,原理就和分库分表是一样的。
          4. 分库分表
            1. 这是我目前最不推崇的方案,技术发展,为了解决这个问题,给业务带来了很多约束,目前这类的框架也有很多
              1. 客户端分库分表:sharding sphere(sharding jdbc)
              2. 代理模式:DRDS(阿里云商业版)、MYCAT
              3. 分布式数据库:polarDB、oceanbase、spanner(最早的谷歌的)、cockroachdb、hybrid for mysql、tidb
          5. 通过ES挡住流量
            1. ES本身支持sharding模式,因此,ES本身有很强的水平扩容的能力,每个分片上只保存部分数据;mysql只当做元数据来用。
      3. 关于写的能力瓶颈
        1. 这里我要提到分布式数据库的概念,实际上分布式数据库并不代表就是new sql,现在市面上的一些主流产品大概分两类
          1. 云原生数据库:代表做POLAR DB:
            1. 云原生书库并没有解决高并发写的问题,他的写入还是单点的,因此,这类数据库想提升写的瓶颈,只能升级CPU核数来达到目的。
            2. 这类数据库的特点是,上层是由一个主来写的,下层是计算节点和存储分离的,因此,在数据量方面的扩容能力是相当强的,一个底层存储节点10M大小;应用的协议是quorum nwr + raft,来保证写多、读多的前提下,可以拿到最新值。
          2. 基于sharding模式的new sql:代表作一大堆
            1. 这类的产品太过于复杂了,并且大多数都是闭源的,最早的是2012年google的Spanner,国内最少开始出现的,大概是2018年,有阿里的oceanbase,也有开源和商业版TiDB,这类数据库因为是sharding模式,底层数据是由多个节点来进行写入,因此对于写支持很好。

            2. 分布式数据库的缺点,那自然就是分布式事务了,现在的new sql每个产品对于分布式事务的支持都是不一样的,互相之间有细微的变化,从这单看来,分布式事务并没有业界一个特别成熟和稳定的打法。

            3. 一般来说分布式事务有两种,一种是2PC提交,另一种的percolator;2PC不用多说了,看看percolator的原理:(摘录的文章)

                1. 事务提交前,在客户端 buffer 所有的 update/delete 操作。
                2. Prewrite 阶段:
                  首先在所有行的写操作中选出一个作为 primary,其他的为 secondaries。
                  PrewritePrimary: 对 primaryRow 写入L列(上锁),L列中记录本次事务的开始时间戳。写入L列前会检查:
                  是否已经有别的客户端已经上锁 (Locking)。
                  是否在本次事务开始时间之后,检查 W 列,是否有更新 [startTs, +Inf) 的写操作已经提交 (Conflict)。
                  在这两种种情况下会返回事务冲突。否则,就成功上锁。将行的内容写入 row 中,时间戳设置为 startTs。
                  将 primaryRow 的锁上好了以后,进行 secondaries 的 prewrite 流程:
                  类似 primaryRow 的上锁流程,只不过锁的内容为事务开始时间及 primaryRow 的 Lock 的信息。
                  检查的事项同 primaryRow 的一致。
                  当锁成功写入后,写入 row,时间戳设置为 startTs。
                3. 以上 Prewrite 流程任何一步发生错误,都会进行回滚:删除 Lock,删除版本为 startTs 的数据。
                4. 当 Prewrite 完成以后,进入 Commit 阶段,当前时间戳为 commitTs,且 commitTs> startTs :
                  commit primary:写入 W 列新数据,时间戳为 commitTs,内容为 startTs,表明数据的最新版本是 startTs 对应的数据。
                  删除L列。
                  如果 primary row 提交失败的话,全事务回滚,回滚逻辑同 prewrite。如果 commit primary 成功,则可以异步的 commit secondaries, 流程和 commit primary 一致, 失败了也无所谓。
              image.png
              image.png

    相关文章

      网友评论

          本文标题:我公司的技术架构演化(三)高并发能力

          本文链接:https://www.haomeiwen.com/subject/fffxsltx.html