01 概述 整个软件的发展历程是一部软件复杂性对抗史,软件的复杂性分为技术复杂性和业务复杂性,业务复杂性主要是建模和抽象设计,技术复杂性主要是三高(高性能,高并发,高可用)的应对,C端的业务一般以技术复杂性为主,业务复杂性为辅,而B端或者M端的业务通常以业务复杂性为主,技术复杂性为辅。本篇文章主要是从后端研发的视角结合自己多年的B、C端系统建设实践谈下三高系统的建设方法论和实践,希望和大家相互交流,共同进步。
02 高性能篇
整个软件的发展历程是一部软件复杂性对抗史,软件的复杂性分为技术复杂性和业务复杂性,业务复杂性主要是建模和抽象设计,技术复杂性主要是三高(高性能,高并发,高可用)的应对,C端的业务一般以技术复杂性为主,业务复杂性为辅,而B端或者M端的业务通常以业务复杂性为主,技术复杂性为辅。本篇文章主要是从后端研发的视角结合自己多年的B、C端系统建设实践谈下三高系统的建设方法论和实践,希望和大家相互交流,共同进步。
高性能篇
2.1 方法论
首先我们要清楚知道影响系统性能的因素有那些,通常有以下三方面的因素:计算(computation),通信(communication),存储(storage)。计算层面:系统本身的计算逻辑复杂,Fullgc;通信层面:依赖的下游耗时比较高;存储层面:大库大表,慢sql,ES集群的数据节点,索引,分片,分片大小设置的不合理;针对这些问题,我们可以从读写两个维度针对性能问题进行优化,下图是我工作中解决性能问题的一些方法。
2.2 几个实践问题的探讨
2.2.1 读优化:缓存和数据库的结合艺术
2.2.1.1 读多写少的系统
针对读多写少的系统,我们一般采用同步更新数据库,后删除缓存;数据库来应对写的流量,缓存来应对读的流量,提高读的性能;此种方案我们是以数据库数据为主,缓存数据为辅,这是前司大部分团队采用的技术方案。
2.2.1.2 写多读少的系统
针对写多读少的系统,我们一般采用同步更新缓存,异步更新数据库,通过缓存来进行抗写的流量,异步化更新数据库,通过缓存和异步化提高系统的性能;此种技术方案以缓存数据为主,数据库数据为辅,这是我了解到的京东物流这边大部分团队的技术方案,例如我们物流平台-统一平台小组的订运关系单据的存储采用的就是这种技术方案。
2.2.2 写优化:秒杀场景下的异步化
针对于这种流量洪峰下的秒杀场景,对于接单接口的性能是很大的考验,所以接单接口不会有很多同步交互的复杂逻辑。我们一般都是先异步将订单接下来,返回给用户成功,通过消息队列来削峰处理订单,缓存存储相关sku的库存,当扣减库存成功后,再短信通知用户支付订单。
高并发篇
3.1 方法论
3.1.1 X轴:水平扩展
水平扩展就是扩容,是我们采用最多的抗并发的措施:加机器,扩分片,每年618,双11大促时,这是我们的常规操作,现有分组下的机器处理能力有限,我们通过扩容来应对大促的流量,扩容我们分为应用层和存储层,应用层都是无状态的服务,我们可以通过公司部署平台行云快速的扩容增加机器,存储层的扩容相对比较麻烦,新增分片后,还涉及到数据的迁移以及分片规则的调整。
3.1.2 Y轴:纵向扩展
整个软件应用的架构经历单体应用,SOA, 微服务,服务网格的演进。早期的架构风格所有的服务功能都融合在单体应用中,通过单体服务来抗所有的流量,后来随着业务的复杂性和用户的增多以及我们基于对业务的深入理解采用DDD(领域驱动设计)来指导我们按照领域划分服务,进行微服务建设,下图是一个电商的单体应用到按照DDD进行微服务划分的一个演进过程。
3.1.3 Z轴:垂直扩展
当我们在应用层进行水平扩展时,每增加一台机器都会增加数据库的访问链接,数据库的连接数属于宝贵资源,达到上限后,应用层再扩容就会出现连接数耗尽的异常,此时存储层成了整个系统高并发的瓶颈,针对数据库我们一般采用分片(分而治之)的思想:分库分表,通过增加库实例来增加访问的连接数,下图是订单进行分库分表的架构。
集群中数据库的主从库数量是有限制的的,达到最大限制后,一个机房的数据库集群成了系统的瓶颈,解决方案是进行单元化建设,系统的流量和数据闭环在一个单元,这个单元分布在全国甚至全世界不同的地域,而不是集中在某个机房某个地域,北京的系统单元为北京用户提供服务,上海的系统单元为上海用户提供服务,就类似于京东物流的仓库一样,建在离用户最近的地方,北京仓服务北京用户,上海仓服务上海用户。大家可以看到系统的建设和业务的发展底层的思想都是统一的,中国的头部互联网还都是业务驱动,所以技术要服务好业务。总之我们可以看到通过分库分表和单元化这种垂直扩展提高并发的同时也增强了系统的可用性,下图是我们进行单元化建设的过程。
3.2 几个实践问题的探讨
3.2.1 DDD在零售物流平台的实践
3.2.1.1 业务流程
正向流程:从B端商家视角来看,商家选择服务商品比如卓配产品后,进行下单,服务商进行接单,分配快递员,快递员上门取件,对用户邮寄的货品进行称重量方,询价计费,然后商家支付运费,快递员完成揽收,进入履约层面:货品进行运输,配送,直至C端用户签收完成妥投。
3.2.1.2 应用领域划分
从领域划分角度来看,将领域划分为:商品服务域,订单域,支付结算域,履约域,每个领域包括了提供的功能如下图;至于为啥这样划分,我的思考是这样的,相较于C端零售侧的电商交易,我们是服务于商家的B端物流侧的物流交易;C端零售针对于用户提供的是实物的商品:手机,电脑;B端物流针对于商家提供的是虚拟的商品:一种履约物流服务,将货品从商家交付到用户;所以无论是我们的卓配产品还是电子面单产品,我们为商户提供了这些虚拟商品,商家选择这些虚拟商品后,就可以下单,取消,改单,支付,服务商进行履约;
3.2.2 热key处理
本地缓存:在应用层增加本地缓存;先查本地缓存,本地缓存没有查询分布式缓存,分布式缓存没有查询数据库;
随机数法:针对某个key,我们可以在这个key后面增加一个随机数,比如增加两位的随机,就可以将该key分散到100个分片上,避免热点分片。
保证系统的可用性是系统建设中的重中之重,如果没有可用性,高性能和高并发也无从谈起,高可用的建设通常是通过保护系统和冗余的方法来进行容错保证系统的可用性。本篇主要从三个维度:应用层,存储层,部署层谈下可用性的建设。应用层的内容来自我的另一篇文章:万字长文浅谈系统稳定性建设。
4.1 方法论
4.1.1 应用层
4.1.1.1 限流
优点 | 缺点 | |
流量计数器算法 | 简单好理解 | 单位时间很难把控,不平滑 |
滑动时间窗口算法 | 时间好把控 | 1 超过窗口时间的流量就丢弃或降级 2 没有办法削峰填谷 |
漏桶算法 | 削峰填谷 | 1 漏桶大小的控制,太大给服务端造成压力,太小大量请求被丢弃 2 漏桶给下游发送请求的速率固定 |
令牌桶算法 | 1 削峰填谷 2 动态控制令牌桶的大小,从而控制向下游发送请求的速率 | 1 实现相对复杂 2 只能预先设计不适配突发 |
4.1.1.2 熔断降级
人工降级:人工降级一般采用降级开关来控制,公司内部一般采用配置中心Ducc来做开关降级,开关的修改也是线上操作,这块也需要做好监控;
自动降级:自动降级是采用自动化的中间件例如Hystrix,公司的小盾龙等;如果采用自动降级的话;我们必须要对降级的条件非常的明确,比如失败的调用次数等。
4.1.1.3 超时设置
超时时间在设置的时候需要遵循漏斗原则,从上游系统到下游系统设置的超时时间要逐渐减少,如下图所示。为什么要满足漏斗原则,假设不满足漏斗原则,比如服务A调取服务B的超时时间设置成500ms,而服务B调取服务C的超时时间设置成800ms,这个时候回导致服务A调取服务B大量的超时从而导致可用率降低,而此时服务B从自身角度看是可用的。
4.1.1.4 重试
分布式系统中性能的影响主要是通信,无论是在分布式系统中还是垮团队沟通,communication是最昂贵的;比如我们研发都知道需求的交付有一半以上甚至更多的时间花在跨团队的沟通上,真正写代码的时间是很少的;分布式系统中我们查看调用链路,其实我们系统本身计算的耗时是很少的,主要来自于外部系统的网络交互,无论是下游的业务系统,还是中间件:Mysql, redis, es等等;所以在和外部系统的一次请求交互中,我们系统是希望尽最大努力得到想要的结果,但往往事与愿违,由于不可靠网络的原因,我们在和下游系统交互时,都会配置超时重试次数,希望在可接受的SLA范围内一次请求拿到结果,但重试不是无限的重试,我们一般都是配置重试次数的限制,偶尔抖动的重试可以提高我们系统的可用率,如果下游服务故障挂掉,重试反而会增加下游系统的负载,从而增加故障的严重程度。在一次请求调用中,我们要知道对外提供的API,后面是有多少个service在提供服务,如果调用链路比较长,服务之间rpc交互都设置了重试次数,这个时候我们需要警惕重试风暴。如下图service D 出现问题,重试风暴会加重service D的故障严重程度。对于API的重试,我们还要区分该接口是读接口还是写接口,如果是读接口重试一般没什么影响,写接口重试一定要做好接口的幂等性。
4.1.1.5 隔离
4.1.1.5.1 系统建设层面隔离
4.1.1.5.2 环境的隔离
从研发到上线阶段我们会使用不同的环境,比如业界常见的环境分为:开发,测试,预发和线上环境;研发人员在开发环境进行开发和联调,测试人员在测试环境进行测试,运营和产品在预发环境进行UAT,最终交付的产品部署到线上环境提供给用户使用。在研发流程中,我们部署时要遵循从应用层到中间件层再到存储层,都要在一个环境,严禁垮环境的调用,比如测试环境调用线上,预发环境调用线上等。
4.1.1.5.3 数据隔离
随着业务的发展,我们对外提供的服务往往会支撑多业务,多租户,所以这个时候我们会按照业务进行数据隔离;比如我们组产生的物流订单数据业务方就包含京东零售,其他电商平台,ISV等,为了避免彼此的影响我们需要在存储层对数据进行隔离,数据的隔离可以按照不同粒度,第一种是通过租户id字段进行区分,所有的数据存储在一张表中,另外一个是库粒度的区分,不同的租户单独分配对应的数据库。
4.1.1.5.4 核心/非核心流程隔离
我们知道应用是分级的,京东内部针对应用的重要程度会将应用分为0,1,2,3级应用。业务的流程也分为黄金流程和非黄金流程。在业务流程中,针对不同级别的应用交互,需要将核心和非核心的流程进行隔离。例如在交易业务过程中,会涉及到订单系统,支付系统,通知系统,那这个过程中核心系统是订单系统和支付系统,而通知相对来说重要性不是那么高,所以我们会投入更多的资源到订单系统和支付系统,优先保证这两个系统的稳定性,通知系统可以采用异步的方式与其他两个系统解耦隔离,避免对其他另外两个系统的影响。
4.1.1.5.5 读写隔离
应用层面,领域驱动设计(DDD)中最著名的CQRS(Command Query Responsibility Segregation)将写服务和读服务进行隔离。写服务主要处理来自客户端的command写命令,而读服务处理来自客户端的query读请求,这样从应用层面进行读写隔离,不仅可以提高系统的可扩展性,同时也会提高系统的可维护性,应用层面我们都采用微服务架构,应用层都是无状态服务,可以扩容加机器随意扩展,存储层需要持久化,扩展就比较费劲。除了应用层面的CQRS,在存储层面,我们也会进行读写隔离,例如数据库都会采用一主多从的架构,读请求可以路由到从库从而分担主库的压力,提高系统的性能和吞吐量。所以应用层面通过读写隔离主要解决可扩展问题,存储层面主要解决性能和吞吐量的问题。
4.1.1.5.6 线程池隔离
线程是昂贵的资源,为了提高线程的使用效率,复用线程,避免创建和销毁的消耗,我们采用了池化技术,线程池,但是在使用线程的过程中,我们也做好线程池的隔离,避免多个API接口复用同一个线程。
4.1.1.6 兼容
向前兼容性:向前兼容性指的是旧版本的软件或硬件能够与将来推出的新版本兼容的特性,简而言之旧版本软件或系统兼容新的数据和流量。
向后兼容性:向后兼容性则是指新版本的软件或硬件能够与之前版本的系统或组件兼容的特性,简而言之新版本软件或系统兼容老的数据和流量。
4.1.2 存储层
4.1.2.1 复制
主从复制:客户端将所有写入操作发送到单个节点(主库),该节点将数据更改事件流发送到其他副本(从库)。读取可以在任何副本上执行,但从库的读取结果可能是陈旧的。
多主复制:客户端将每个写入发送到几个主库节点之一,其中任何一个主库都可以接受写入。主库将数据更改事件流发送给彼此以及任何从库节点。
无主复制:客户端将每个写入发送到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
4.1.2.2 分区
4.1.2.3 Redis 的复制和分片
redis cluster集群中,我们会划分16384个槽,key 通过散列哈希算法会映射到相应的槽中,这些槽分配到不同的分片上,每个分片有主节点和从节点,主节点对外提供读写服务,从节点对外提供读服务。当某个分片的主节点挂掉,其他分片的主节点会从挂掉分片的从节点选择一个作为主节点继续对外提供服务。整体的架构如下图所示。
4.1.2.4 ES索引的复制和分片
我们在创建ES索引时,会指定分片的数量和副本的数量,分片的数量确定后是不允许修改的,副本的数量允许修改,分片的数量一般和数据节点的数量保持一致,这样能将索引的数据分配到每个数据节点上,每个数据节点都存储索引的部分数据,Primary分片可以对外提供读写服务,Replica分片对外提供读服务的同时作为备份节点保证可用性,ES索引的不同分片在不同数据节点的分布如下图所示。
4.1.2.5 Kafka topic的复制和分区
4.1.3 部署层
4.1.3.1 业界部署架构的演进
部署层是通过不断突破单机器,单机房,单地域,做到机器级别,机房级别,地域级别的容灾来保证系统的高可用。核心思想是通过冗余以及负载均衡进行容灾保证高可用。
4.1.3.2 我们部署架构现状
应用容器机房为:中云信,有孚,廊坊,宿迁等;
数据库Mysql双机房部署:中云信,有孚;
缓存Redis双机房部署:中云信,有孚;
ES单机房部署:有孚。
总结
软件的发展历程就像一场与复杂性对抗的持久战,这场战争主要围绕着两个主要的战场:技术复杂性和业务复杂性。在这篇文章中,我从后端研发的视角出发,深入探讨多年来来我在B\C端系统建设方面的宝贵经验和实践,特别关注那些需要同时应对高性能、高并发和高可用性的系统设计和优化策略,希望和大家多多交流,相互探讨。