怎么提高自己的系统设计和架构理论水平?

本文由 简悦 SimpRead 转码, 原文地址 www.zhihu.com https://pica.zhimg.com/v2-7a1eebaddf0313f659a4fe901a1d2fd1_xs.jpg?source=1940ef5c极客重生​

想要提高自己架构设计能力,需要知道一些架构设计的套路:

架构设计核心支持大规模用户的高性能,高并发,高可用,可扩展分布式系统

其他的就是围绕架构核心思想去搭建自己的系统(权衡成本)

现实中系统都是逐步演进出来的,而不是提前设计好的

因为互联网发展核心思想是:

小步快跑,快速迭代,即先上线(最核心最重要的功能)抢占市场,再迭代优化(增加其他功能)

所以随着用户增加,架构就必须要升级重构! 这个在互联网家常便饭!

看一下随着用户规模增大,架构也在不断进行演进

https://pica.zhimg.com/v2-60096fddb872880f35a17b8af2129939_r.jpg?source=1940ef5c

高性能,高并发

并发是操作系统领域的一个概念,指的是一段时间内多任务流交替执行的现象,后来这个概念被泛化,高并发用来指大流量、高请求的业务情景,比如春运抢票,电商双十一,秒杀大促等场景。

设计思路有两个方向:

  1. 垂直方向扩展,也叫竖向扩展
  2. 水平方向扩展,也叫横向扩展

垂直方向:提升单机能力

提升单机处理能力又可分为硬件和软件两个方面:

• 硬件方向,很好理解,花钱升级机器,更多核更高主频更大存储空间更多带宽

• 软件方向,包括用各快的数据结构,改进架构,应用多线程、协程,以及上性能优化各种手段,但这玩意儿天花板低,就像提升个人产出一样,996、007、最多 24 X 7。

水平方向:分布式集群

为了解决分布式系统的复杂性问题,一般会用到架构分层和服务拆分,通过分层做隔离,通过微服务解耦。

这个理论上没有上限,只要做好层次和服务划分,加机器扩容就能满足需求,但实际上并非如此,一方面分布式会增加系统复杂性,另一方面集群规模上去之后,也会引入一堆 AIOps、服务发现、服务治理的新问题。

因为垂直向的限制,所以,我们通常更关注水平扩展,高并发系统的实施也主要围绕水平方向展开。

数据库层面:分库分表 + 读写分离

采用消息队列

应用解耦:多应用间通过消息队列对同一消息进行处理,避免调用接口失败导致整个过程失败;

异步处理:多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间;

限流削峰:广泛应用于秒杀或抢购活动中,避免流量过大导致应用系统挂掉的情况;

深入理解 Kafka 的设计思想

高可用:

谈到高可用,不得不说一下 cap 定理,这个是高可用系统设计者必须要理解原理,只有深刻理解这个定理,才能再各种方案中找到适合自己的最佳方案。

CAP 定理(CAP theorem)又被称作布鲁尔定理(Brewer’s theorem),是加州大学伯克利分校的计算机科学家埃里克 · 布鲁尔(Eric Brewer)在 2000 年的 ACM PODC 上提出的一个猜想。2002 年,麻省理工学院的赛斯 · 吉尔伯特(Seth Gilbert)和南希 · 林奇(Nancy Lynch)发表了布鲁尔猜想的证明,使之成为分布式计算领域公认的一个定理。对于设计分布式系统的架构师来说,CAP 是必须掌握的理论。

在一个分布式系统(指互相连接并共享数据的节点的集合)中,当涉及读写操作时,只能保证一致性(Consistence)、可用性(Availability)、分区容错性(Partition Tolerance)三者中的两个,另外一个必须被牺牲

同时理解 SLA 指标

https://picx.zhimg.com/v2-177e50a24e9891a7892cdb4fd0277d8b_r.jpg?source=1940ef5c

常见套路:

常见的计算高可用架构:主备、主从和集群,异地多活架构

可扩展(快速迭代的基础)

软件系统的这种天生和内在的可扩展的特性,既是魅力所在,又是难点所在。魅力体现在我们可以通过修改和扩展,不断地让软件系统具备更多的功能和特性,满足新的需求或者顺应技术发展的趋势。而难点体现在如何以最小的代价去扩展系统,因为很多情况下牵一发动全身,扩展时可能出现到处都要改,到处都要推倒重来的情况。这样做的风险不言而喻:改动的地方越多,投入也越大,出错的可能性也越大。因此,如何避免扩展时改动范围太大,是软件架构可扩展性设计的主要思考点。

核心思路:分层设计,中间层降低耦合,功能拆分(功能独立性),面向接口编程等

我们不能掌握了大方案,吹完了牛皮,而忽视了编程最本质的东西,掌握最基本最核心的编程能力,比如数据架构和算法,设计,惯用法,培养技术的审美,也是很重要的,既要致高远,又要尽精微。我建议先从代码设计(模块设计)开始练习,代码设计核心是低耦合,高内聚。

分享一下我的代码模式理解,希望可以帮助你打好基础:

https://pic4.zhimg.com/v2-f7f8f648e417f347348e94e6fdb31009_r.jpg?source=1940ef5c

在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希 · 伽玛(Erich Gamma)等人在 1990 年代从建筑设计领域引入到计算机科学的,设计模式是针对软件设计中常见问题的工具箱,其中的工具就是各种经过实践验证的解决方案。即使你从未遇到过这些问题,了解模式仍然非常件有用,因为它能指导你如何使用面向对象的设计原则来解决各种问题。

https://pic4.zhimg.com/v2-a6912c835f2aa46f48300cda4e7e4223_r.jpg?source=1940ef5c

为什么要掌握设计模式

历史的教训

时间回到 20 世纪 80 年代,当时的软件行业正处于第二次软件危机中。根本原因是,随着软件规模和复杂度的快速增长,如何高效高质的构建和维护这样大规模的软件成为了一大难题。无论是开发何种软件产品,成本和时间都最重要的两个维度。较短的开发时间意味着可比竞争对手更早进入市场;较低的开发成本意味着能够留出更多营销资金,因此能更广泛地覆盖潜在客户。

设计模式是银弹吗?

代码复用是减少开发成本,减低复杂度最常用的方式之一,这个想法表面看起来很棒,但实际上要让已有代码在全新的上下文中工作,通常还是需要付出额外努力的。组件间紧密的耦合、对具体类而非接口的依赖和硬编码的行为都会降低代码的灵活性,使得复用这些代码变得更加困难。设计模式目标就是帮助软件提高内聚,减低耦合,使用设计模式是增加软件组件灵活性并使其易于复用的方式之一。

变化是程序员生命中唯一不变的事情,客户需求可能经常会变,紧急上线的版本,要不要下次重构一下,还是继续打各种补丁, 技术债会越积越多,因此在设计程序架构时,所有有经验的开发者会尽量选择支持未来任何可能变更的方式。可扩展性成为了程序设计必须要考虑指标,而设计模式是可以借鉴的,成熟的优化程序设计的解决方案;

总结

为什么要掌握设计模式,软件危机带来刚性要求,设计模式提倡的高内聚,低耦合,代码复用,可扩展性等思想,可以给我们软件设计带来一些思考,有了思考,就会产生一些积极变化;

理解设计模式前提,是要理解背后的设计原则,这是整个设计模式的精华;

经典的设计模式包含 22 种设计模式(没有解释器模式,日常开发中,很少使用),大致分为三类:创建型模式,结构型模式,行为模式;

Linux 系统里面包含大量设计模式思想,面向对象设计,List/Rbtree 抽象设计,驱动框架 bus 总线解耦设计,都值得我们学习;

每种编程语言都会有一些独特特殊习惯用法,Java 的 MVC,Golang 的对象池模式 (Object Pool) 等,文中列举的 C++ 一些常见的惯用法 RAII,Policy-based Design ,Pimpl,CRTP 等,对 C++ 开发来说,了解和掌握他们,对于特定场景问题多了一些好的解决方案;

设计模式是银弹吗?不是,就像软件工程也不是银弹一样,这些都只是工具,关键还是看是否真正理解其背后反射出的设计精髓,我们需要多一些批判性的思考,没有绝对好坏,软件设计的最终方案很多时候都是权衡(trade-off)结果,但我们的长期目标始终没有变化。

详细参考:

经典永不过时!重温设计模式

1
如有收获,点个在看,诚挚感谢

https://pic4.zhimg.com/v2-227b5ef96dc4d1b3a45f7233dfb987e1_xs.jpg?source=1940ef5c阿里巴巴大淘宝技术​

系统设计与架构理论这个问题,回答起来太宽泛了,哈哈,基本所有的技术理论都可以涵盖进去,作为一个撸代码快 10 年的后端技术人员,也来简单发表一下我的看法,系统设计与架构,与系统的业务类型关联还是很大的。

比如传统的业务系统主要关注的是领域建模设计,高并发、高可用、数据一致性等系统,在设计的时候会与业务系统有较大的差别,所以这里针对不同类型的系统,来简单介绍一下设计的时候面临的一些难点与解决方案。

(点击头像关注我们账号,别错过更多阿里工程师一线技术干货) ——————————————————————————————————————————

常规业务系统设计关键 - 领域模型

业务系统设计的关键是在于如何定义系统的模型以及模型之间的关系,其中主要是领域模型的定义,当我们在模型确定之后,模型之间的关系也会随之明确。 模型设计可以参考领域模型的经典书籍《Domain-Driven Design》一书,通过这个基本可以对领域定义、防腐层、贫血模型等概念有一个较为清晰的认识了。 单个应用内的领域模型系统也需要注意领域分层,作为开发大家是不是见过、重构过很多 Controller-Service-DAO 样式的代码分层设计?往往在在做重构的时候会令人吐血。

设计较好的领域设计这里给一个分层建议:

  • 接口层 Interface:主要负责与外部系统进行交互 & 通信,比如一些 dubbo 服务、Restful API、RMI 等,这一层主要包括 Facade、DTO 还有一些 Assembler。
  • 应用层 Application:这一层包含的主要组件就是 Service 服务,但是要特别注意,这一层的 Service 不是简单的 DAO 层的包装,在领域驱动设计的架构里面,Service 层只是一层很 “” 的一层,它内部并不实现任何逻辑,只是负责协调和转发、委派业务动作给更下层的领域层
  • 领域层 Domain:Domain 层是领域模型系统的核心,负责维护面向对象的领域模型,几乎全部的业务逻辑都会在这一层实现。内部主要包含 Entity(实体)ValueObject(值对象)Domain Event(领域事件)Repository(仓储) 等多种重要的领域组件。
  • 基础设施层 Infrastructure:它主要为 Interfaces、Application 和 Domain 三层提供支撑。所有与具体平台、框架相关的实现会在 Infrastructure 中提供,避免三层特别是 Domain 层掺杂进这些实现,从而 “污染” 领域模型。Infrastructure 中最常见的一类设施是对象持久化的具体实现。

高并发系统设计

在面试中是不是经常被问到一个问题:如果你系统的流量增加 N 倍你要怎么重新设计你的系统?这个高并发的问题可以从各个层面去解,比如

代码层面

  • 锁优化(采用无锁数据结构),主要是 concurrent 包下面的关于 AQS 锁的一些内容
  • 数据库缓存设计(降低数据库并发争抢压力),这里又会有缓存、DB 数据不一致的问题,在实际使用中,高并发系统和数据一致性系统采用的策略会截然相反。
  • 数据更新时采用合并更新,可以在应用层去做更新合并,同一个 Container 在同一时间只会有一个 DB 更新请求。
  • 其他的比如基于 BloomFilter 的空间换时间、通过异步化降低处理时间、通过多线程并发执行等等。

数据库层面

  • 根据不同的存储诉求来进行不同的存储选型,从早期的 RDBMS,再到 NoSql(KV 存储、文档数据库、全文索引引擎等等),再到最新的 NewSql(TiDB、Google spanner/F1 DB)等等。
  • 表数据结构的设计,字段类型选择与区别。
  • 索引设计,需要关注聚簇索引原理与覆盖索引消除排序等,至于最左匹配原则都是烂大街的常识了,高级一点索引消除排序的一些机制等等,B + 树与 B 树的区别。
  • 最后的常规手段:分库分表、读写分离、数据分片、热点数据拆分等等,高并发往往会做数据分桶,这里面往深了去说又有很多,比如分桶如何初始化、路由规则、最后阶段怎么把数据合并等等,比较经典的方式就是把桶分成一个主桶 + N 个分桶。

架构设计层面:

  • 分布式系统为服务化
  • 无状态化支持水平弹性扩缩容
  • 业务逻辑层面 failfast 快速失败
  • 调用链路热点数据前置
  • 多级缓存设计
  • 提前容量规划等等

高可用系统设计

对于可用性要求非常高的系统,一般我们都说几个 9 的可用率,比如 99.999% 等。 面对高可用系统设计也可以从各个方面来进行分析

代码层面:需要关注分布式事务问题,CAP 理论是面试的常规套路 软件层面: 应用支持无状态化,部署的多个模块完全对等,请求在任意模块处理结果完全一致 => 模块不存储上下文信息,只根据请求携带的参数进行处理。目的是为了快速伸缩,服务冗余。常见的比如 session 问题等。 负载均衡问题: 软件部署多份之后,如何保证系统负载?如何选择调用机器?也就是负载均衡问题

  • 狭义上的负载均衡按照类型可以分为这几种:
  • 硬件负载:比如 F5 等
  • 软件负载:比如 LVS、Ngnix、HaProxy、DNS 等。
  • 当然,还有代码算法上的负载均衡,比如 Random、RoundRobin、ConsistentHash、加权轮训等等算法
  • 广义上的负载均衡可以理解为负载均衡的能力,比如一个负载均衡系统需要如下 4 个能力:
  • 故障机器自动发现
  • 故障服务自动摘除(服务熔断)
  • 请求自动重试
  • 服务恢复自动发现

幂等设计问题

上面提负载均衡的时候,广义负载均衡需要完成自动重试机制,那么在业务上,我们就必须保证幂等设计。 这里可以从 2 个层面来进行考虑:

  • 请求层面:由于请求会重试所以必须做幂等,需要保证请求重复执行和执行一次的结果完全相同。请求层面的幂等设计需要在数据修改的层做幂等,也就是数据访问层读请求天然幂等,写请求需要做幂等。读请求一般是天然幂等的,无论查询多少次返回的结果都是一致。这其中的本质实际上是分布式事务问题,这里下面再详细介绍。
  • 业务层面:不幂等会造成诸如奖励多发、重复下单等非常严重的问题。业务层面的幂等本质上是分布式锁的问题,后面会介绍。如何保证不重复下单?这里比如 token 机制等等。如何保证商品不超卖?比如乐观锁等。MQ 消费方如何保证幂等等都是面试的常见题。

分布式锁

业务层面的幂等设计本质上是分布式锁问题,什么是分布式锁?分布式环境下锁的全局唯一资源,使请求串行化,实际表现互斥锁,解决业务层幂等问题。 常见的解决方式是基于 Redis 缓存的 setnx 方法,但作为技术人员应该清楚这其中还存在单点问题、基于超时时间无法续租问题、异步主从同步问题等等,更深一点,CAP 理论,一个 AP 系统本质上无法实现一个 AP 需求,即使是 RedLock 也不行。 那我们如何去设计一个分布式锁呢?强一致性、服务本身要高可用是最基本的需求,其他的比如支持自动续期,自动释放机制,高度抽象接入简单,可视化、可管理等。 基于存储层的可靠的解决方案比如

  • zookeeper:CP/ZAB/N+1 可用:基于临时节点实现和 Watch 机制。
  • ETCD:CP or AP/Raft/N+1 可用:基于 restful API;KV 存储,强一致性,高可用,数据可靠:持久化;Client TTL 模式,需要心跳 CAS 唯一凭证 uuid。

服务的熔断

微服务化之后,系统分布式部署,系统之间通过 RPC 通讯,整个系统发生故障的概率随着系统规模的增长而增长,一个小的故障经过链路传导放大,有可能造成更大的故障。希望在调用服务的时,在一些非关键路径服务发生服务质量下降的情况下,选择尽可能地屏蔽所造成的影响。 大部分熔断返回默认值 null,也可以定制,RPCClient 原生支持最好,业务方少改代码(熔断放的地方),进入熔断时,打印熔断日志,同时返回 Exception(业务方定制熔断方法),需要有服务治理平台,可以看到服务的状态、是否降级、是否熔断、可以实时下发阀值配置等。

服务降级

服务整体负载超出预设的上限,或者即将到来的流量预计将会超过阀值,为了保证重要或者基本的服务能够正常运行,拒绝部分请求或者将一些不重要的不紧急的服务或任务进行服务的延迟使用或暂停使用

主要的手段如下:

  • 服务层降级,主要手段:
  • 拒绝部分请求(限流),比如缓存请求队列,拒绝部分等待时间长的请求;根据 Head,来拒绝非核心请求;还有其他通用算法上的限流比如令牌桶、漏桶算法等等。
  • 关闭部分服务:比如双 11 大促 0 点会关闭逆向退款服务等等。
  • 分级降级:比如自治式服务降级,从网关到业务到 DB 根据拦截、业务规则逐渐降低下游请求量,体现上是从上到下的处理能力逐渐下降。
  • 数据层降级
  • 比如流量大的时候,更新请求只缓存到 MQ,读请求读缓存,等流量小的时候,进行补齐操作 (一般数据访问层如果做了降级,就没必要在数据层再做了)
  • 柔性可用策略
  • 比如一些指定最大流量的限流工具,又或是根据 CPU 负载的限流工具等,需要保证自动打开,不依赖于人工。

发布方式引发的可用性问题

发布方式也是影响高可用的一个点,哈哈,以前还经历过一些线上直接停机发布的案例(银行内部系统),不过作为高大上的互联网,主要会采用这几种发布方式: 灰度发布、蓝绿发布、金丝雀发布等等。

数据一致性系统设计

一般一些金融、账务系统对这一块要求会非常严格,下面主要介绍下这里面涉及到的事务一致性、一致性算法等内容。

事务一致性问题

在 DB 层面,一般通过 刚性事务 来实现数据一致性,主要通过 预写日志 (WAL) 的方式来实现,WAL(write ahead logging) 预写日志的方式。就是所有对数据文件的修改,必须要先写日志,这样,即使在写数据的时候崩溃了,也能通过日志文件恢复,传统的数据库事务就是基于这一个机制(REDO 已提交事务的数据也求改 UNDO 未提交事务的回滚)。

除了这个方式之外,还有一个就是通过 影子数据块 来进行数据备份,提前记录被修改的数据块的修改前的状态,备份起来,如果需要回滚,直接用这个备份的数据块进行覆盖就好了。 其他的就是基于二阶段提交的 XA 模型 了。 但是目前互联网系统,已经广泛采用分布式部署模式了,传统的刚性事务无法实现,所以 柔性事务成了目前主流的分布式事务解决防范,主要的模式有下面几种:

TCC 模式 / 或者叫 2 阶段模式:在 try 阶段预扣除资源(但是不锁定资源,提升可用性),在 Confirm 或者 Cancel 阶段进行数据提交或者回滚。一般需要引入协调者,或者叫事务管理器。 SAGA 模式:业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,支持向前或者向后补偿。 MQ 的事务消息:就是先发 halfMsg,在处理完之后,再发送 commit 或者 rollback Msg,然后 MQ 会定期询问 producer ,halfMsg 能不能 commit 或者 rollback,最终实现事务的最终一致性。实际上是把补偿的动作委托给了 RocketMQ。 分段事物(异步确保) :基于可靠消息 + 本地事务消息表 + 消息队列重试机制。目前这也是一些大厂的主流方案,内部一般称为分段事物。 柔性事务基本都是基于最终一致性去实现,所以肯定会有 补偿 动作在里面,在达到最终一致性之前,对用户一般展示 软状态。

需要注意的一点是,并不是所有的系统都适合引入数据一致性框架,比如用户可以随时修改自己发起的请求的情况,例如,商家设置后台系统,商户会随时修改数据,这里如果涉及到一致性的话,引入一致性框架会导致补偿动作达到最终一致性之前,资源锁会阻塞用户后续的请求。导致体验较差。这种情况下就需要通过其他手段来保障数据一致性了,比如数据对账等操作。

一致性算法

从早期的 Paxos 算法,再到后面衍生的 zab 协议(参考:A simple totally ordered broadcast protocol),提供了当下可靠的分布式锁的解决方案。再到后来的 Raft 算法(In Search of an Understandable Consensus Algorithm),也都是分布式系统设计里面需要了解到的一些知识要点。 ——————————————————————————————————————————

最后

这里简单介绍了不同系统设计的时候会面临的一些难点,基本里面每一个点,都是前人在解决各种疑难问题的道路上不断探索,最终才得出的这些业界解决方案,呈现在大家眼前,作为一个技术人员,学会这些技术点只是时间问题,但这种发现问题、直面问题、再到解决问题的能力和精神才是我们最值得学习的地方,也是做为一个系统设计人员或者说是架构师的必要能力。 最后,祝进步~!

(本篇回答作者:阿里巴巴淘系技术部 勇剑)

————————————————————————————————————————

阿里巴巴集团淘系技术部官方账号。淘系技术部是阿里巴巴新零售技术的王牌军,支撑淘宝、天猫核心电商以及淘宝直播、闲鱼、躺平、阿里汽车、阿里房产等创新业务,服务 9 亿用户,赋能各行业 1000 万商家。我们打造了全球领先的线上新零售技术平台,并作为核心技术团队保障了 11 次双十一购物狂欢节的成功。

https://pic3.zhimg.com/v2-97b585c63aa8a7c2a8937a1d53b78ce1_xs.jpg?source=1940ef5c腾讯技术工程​

来分享下鹅厂后台开发同学 @boolean 的后台服务架构高性能设计之道。

“N 高 N 可”,高性能、高并发、高可用、高可靠、可扩展、可维护、可用性等是后台开发耳熟能详的词了,它们中有些词在大部分情况下表达相近意思。本序列文章旨在探讨和总结后台架构设计中常用的技术和方法,并归纳成一套方法论。

前言

本文主要探讨和总结服务架构设计中高性能的技术和方法,如下图的思维导图所示,左边部分主要偏向于编程应用,右边部分偏向于组件应用,文章将按图中的内容展开。

https://pica.zhimg.com/v2-18fb806f45427f77a0be8a0d11a4df98_r.jpg?source=1940ef5c

1 无锁化

大多数情况下,多线程处理可以提高并发性能,但如果对共享资源的处理不当,严重的锁竞争也会导致性能的下降。面对这种情况,有些场景采用了无锁化设计,特别是在底层框架上。无锁化主要有两种实现,串行无锁和数据结构无锁。

1.1 串行无锁

无锁串行最简单的实现方式可能就是单线程模型了,如 redis/Nginx 都采用了这种方式。在网络编程模型中,常规的方式是主线程负责处理 I/O 事件,并将读到的数据压入队列,工作线程则从队列中取出数据进行处理,这种半同步 / 半异步模型需要对队列进行加锁,如下图所示:

https://pic2.zhimg.com/v2-9dbf6b87a99f71a37dd98c04186203c1_r.jpg?source=1940ef5c

上图的模式可以改成无锁串行的形式,当 MainReactor accept 一个新连接之后从众多的 SubReactor 选取一个进行注册,通过创建一个 Channel 与 I/O 线程进行绑定,此后该连接的读写都在同一个线程执行,无需进行同步。

https://pic1.zhimg.com/v2-410d9edca404ec597be58fb00b1cf08e_r.jpg?source=1940ef5c

1.2 结构无锁

利用硬件支持的原子操作可以实现无锁的数据结构,很多语言都提供 CAS 原子操作(如 go 中的 atomic 包和 C++11 中的 atomic 库),可以用于实现无锁队列。我们以一个简单的线程安全单链表的插入操作来看下无锁编程和普通加锁的区别。

1
2
3
4
5
6
7
template<typename T>
struct Node
{
    Node(const T &value) : data(value) { }
    T data;
    Node *next = nullptr;
};

有锁链表 WithLockList:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
template<typename T>
class WithLockList
{
    mutex mtx;
    Node<T> *head;
public:
    void pushFront(const T &value)
    {
        auto *node = new Node<T>(value);
        lock_guard<mutex> lock(mtx); //①
        node->next = head;
        head = node;
    }
};

无锁链表 LockFreeList:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
template<typename T>
class LockFreeList
{
    atomic<Node<T> *> head;
public:
    void pushFront(const T &value)
    {
        auto *node = new Node<T>(value);
        node->next = head.load();
        while(!head.compare_exchange_weak(node->next, node)); //②
    }
};

从代码可以看出,在有锁版本中 ① 进行了加锁。在无锁版本中,② 使用了原子 CAS 操作 compare_exchange_weak,该函数如果存储成功则返回 true,同时为了防止伪失败(即原始值等于期望值时也不一定存储成功,主要发生在缺少单条比较交换指令的硬件机器上),通常将 CAS 放在循环中。

下面对有锁和无锁版本进行简单的性能比较,分别执行 1000,000 次 push 操作。测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int main()
{
    const int SIZE = 1000000;
    //有锁测试
    auto start = chrono::steady_clock::now();
    WithLockList<int> wlList;
    for(int i = 0; i < SIZE; ++i)
    {
        wlList.pushFront(i);
    }
    auto end = chrono::steady_clock::now();
    chrono::duration<double, std::micro> micro = end - start;
    cout << "with lock list costs micro:" << micro.count() << endl;

    //无锁测试
    start = chrono::steady_clock::now();
    LockFreeList<int> lfList;
    for(int i = 0; i < SIZE; ++i)
    {
        lfList.pushFront(i);
    }
    end = chrono::steady_clock::now();
    micro = end - start;
    cout << "free lock list costs micro:" << micro.count() << endl;

    return 0;
}

三次输出如下,可以看出无锁版本有锁版本性能高一些。 with lock list costs micro:548118 free lock list costs micro:491570 with lock list costs micro:556037 free lock list costs micro:476045 with lock list costs micro:557451 free lock list costs micro:481470

2 零拷贝

这里的拷贝指的是数据在内核缓冲区和应用程序缓冲区直接的传输,并非指进程空间中的内存拷贝(当然这方面也可以实现零拷贝,如传引用和 C++ 中 move 操作)。现在假设我们有个服务,提供用户下载某个文件,当请求到来时,我们把服务器磁盘上的数据发送到网络中,这个流程伪代码如下:

1
2
3
4
5
filefd = open(...); //打开文件
sockfd = socket(...); //打开socket
buffer = new buffer(...); //创建buffer
read(filefd, buffer); //从文件内容读到buffer中
write(sockfd, buffer); //将buffer中的内容发送到网络

数据拷贝流程如下图:

https://picx.zhimg.com/v2-6e95d5848e95bdca189c2f0063e12976_r.jpg?source=1940ef5c

上图中绿色箭头表示 DMA copy,DMA(Direct Memory Access)即直接存储器存取,是一种快速传送数据的机制,指外部设备不通过 CPU 而直接与系统内存交换数据的接口技术。红色箭头表示 CPU copy。即使在有 DMA 技术的情况下还是存在 4 次拷贝,DMA copy 和 CPU copy 各 2 次。

2.1 内存映射

内存映射将用户空间的一段内存区域映射到内核空间,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间,简单来说就是用户空间共享这个内核缓冲区。

使用内存映射来改写后的伪代码如下:

1
2
3
4
filefd = open(...); //打开文件
sockfd = socket(...); //打开socket
buffer = mmap(filefd); //将文件映射到进程空间
write(sockfd, buffer); //将buffer中的内容发送到网络

使用内存映射后数据拷贝流如下图所示:

https://picx.zhimg.com/v2-d68710dfa0b5f8ff92c0dedca692ea3f_r.jpg?source=1940ef5c

从图中可以看出,采用内存映射后数据拷贝减少为 3 次,不再经过应用程序直接将内核缓冲区中的数据拷贝到 Socket 缓冲区中。RocketMQ 为了消息存储高性能,就使用了内存映射机制,将存储文件分割成多个大小固定的文件,基于内存映射执行顺序写。

2.2 零拷贝

零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储,从而有效地提高数据传输效率的技术。Linux 内核 2.4 以后,支持带有 DMA 收集拷贝功能的传输,将内核页缓存中的数据直接打包发到网络上,伪代码如下:

1
2
3
filefd = open(...); //打开文件
sockfd = socket(...); //打开socket
sendfile(sockfd, filefd); //将文件内容发送到网络

使用零拷贝后流程如下图:

https://pic2.zhimg.com/v2-d68710dfa0b5f8ff92c0dedca692ea3f_r.jpg?source=1940ef5c

零拷贝的步骤为: 1)DMA 将数据拷贝到 DMA 引擎的内核缓冲区中; 2)将数据的位置和长度的信息的描述符加到套接字缓冲区; 3)DMA 引擎直接将数据从内核缓冲区传递到协议引擎;

可以看出,零拷贝并非真正的没有拷贝,还是有 2 次内核缓冲区的 DMA 拷贝,只是消除了内核缓冲区和用户缓冲区之间的 CPU 拷贝。Linux 中主要的零拷贝函数有 sendfile、splice、tee 等。下图是来住 IBM 官网上普通传输和零拷贝传输的性能对比,可以看出零拷贝比普通传输快了 3 倍左右,Kafka 也使用零拷贝技术。

https://picx.zhimg.com/v2-1765c83a1fa07c42b44fe97ec368cf74_r.jpg?source=1940ef5c

3 序列化

当将数据写入文件、发送到网络、写入到存储时通常需要序列化(serialization)技术,从其读取时需要进行反序列化(deserialization),又称编码(encode)和解码(decode)。序列化作为传输数据的表示形式,与网络框架和通信协议是解耦的。如网络框架 taf 支持 jce、json 和自定义序列化,HTTP 协议支持 XML、JSON 和流媒体传输等。

序列化的方式很多,作为数据传输和存储的基础,如何选择合适的序列化方式尤其重要。

3.1 分类

通常而言,序列化技术可以大致分为以下三种类型:

  • 内置类型:指编程语言内置支持的类型,如 java 的 java.io.Serializable。这种类型由于与语言绑定,不具有通用性,而且一般性能不佳,一般只在局部范围内使用。
  • 文本类型:一般是标准化的文本格式,如 XML、JSON。这种类型可读性较好,且支持跨平台,具有广泛的应用。主要缺点是比较臃肿,网络传输占用带宽大。
  • 二进制类型:采用二进制编码,数据组织更加紧凑,支持多语言和多平台。常见的有 Protocol Buffer/Thrift/MessagePack/FlatBuffer 等。

3.2 性能指标

衡量序列化 / 反序列化主要有三个指标: 1)序列化之后的字节大小; 2)序列化 / 反序列化的速度; 3)CPU 和内存消耗;

下图是一些常见的序列化框架性能对比:

https://pica.zhimg.com/v2-a160d3dd3451855b4ea902b41d0f688e_r.jpg?source=1940ef5chttps://pic1.zhimg.com/v2-11489e34bfc8736a1229b4922d4d5aa6_r.jpg?source=1940ef5c

可以看出 Protobuf 无论是在序列化速度上还是字节占比上可以说是完爆同行。不过人外有人,天外有天,听说 FlatBuffer 比 Protobuf 更加无敌,下图是来自 Google 的 FlatBuffer 和其他序列化性能对比,光看图中数据 FB 貌似秒杀 PB 的存在。

https://pic1.zhimg.com/v2-496e31af686ab47d032d3b8c0f29d591_r.jpg?source=1940ef5c

3.3 选型考量

在设计和选择序列化技术时,要进行多方面的考量,主要有以下几个方面: 1)性能:CPU 和字节占用大小是序列化的主要开销。在基础的 RPC 通信、存储系统和高并发业务上应该选择高性能高压缩的二进制序列化。一些内部服务、请求较少 Web 的应用可以采用文本的 JSON,浏览器直接内置支持 JSON。 2)易用性:丰富数据结构和辅助工具能提高易用性,减少业务代码的开发量。现在很多序列化框架都支持 List、Map 等多种结构和可读的打印。 3)通用性:现代的服务往往涉及多语言、多平台,能否支持跨平台跨语言的互通是序列化选型的基本条件。 4)兼容性:现代的服务都是快速迭代和升级,一个好的序列化框架应该有良好的向前兼容性,支持字段的增减和修改等。 5)扩展性:序列化框架能否低门槛的支持自定义的格式有时候也是一个比较重要的考虑因素。

4 池子化

池化恐怕是最常用的一种技术了,其本质就是通过创建池子来提高对象复用,减少重复创建、销毁的开销。常用的池化技术有内存池、线程池、连接池、对象池等。

4.1 内存池

我们都知道,在 C/C++ 中分别使用 malloc/free 和 new/delete 进行内存的分配,其底层调用系统调用 sbrk/brk。频繁的调用系统调用分配释放内存不但影响性能还容易造成内存碎片,内存池技术旨在解决这些问题。正是这些原因,C/C++ 中的内存操作并不是直接调用系统调用,而是已经实现了自己的一套内存管理,malloc 的实现主要有三大实现。

1)ptmalloc:glibc 的实现。

2)tcmalloc:Google 的实现。

3)jemalloc:Facebook 的实现。

下面是来自网上的三种 malloc 的比较图,tcmalloc 和 jemalloc 性能差不多,ptmalloc 的性能不如两者,我们可以根据需要选用更适合的 malloc,如 redis 和 mysl 都可以指定使用哪个 malloc。至于三者的实现和差异,可以网上查阅。

https://pic1.zhimg.com/v2-f8851e9a19050f4b324c5b6cf7b05b0c_r.jpg?source=1940ef5c

虽然标准库的实现在操作系统内存管理的基础上再加了一层内存管理,但应用程序通常也会实现自己特定的内存池,如为了引用计数或者专门用于小对象分配。所以看起来内存管理一般分为三个层次。

![](data:image/svg+xml;utf8,)

4.2 线程池

线程创建是需要分配资源的,这存在一定的开销,如果我们一个任务就创建一个线程去处理,这必然会影响系统的性能。线程池的可以限制线程的创建数量并重复使用,从而提高系统的性能。

线程池可以分类或者分组,不同的任务可以使用不同的线程组,可以进行隔离以免互相影响。对于分类,可以分为核心和非核心,核心线程池一直存在不会被回收,非核心可能对空闲一段时间后的线程进行回收,从而节省系统资源,等到需要时在按需创建放入池子中。

4.3 连接池

常用的连接池有数据库连接池、redis 连接池、TCP 连接池等等,其主要目的是通过复用来减少创建和释放连接的开销。连接池实现通常需要考虑以下几个问题:

1)初始化:启动即初始化和惰性初始化。启动初始化可以减少一些加锁操作和需要时可直接使用,缺点是可能造成服务启动缓慢或者启动后没有任务处理,造成资源浪费。惰性初始化是真正有需要的时候再去创建,这种方式可能有助于减少资源占用,但是如果面对突发的任务请求,然后瞬间去创建一堆连接,可能会造成系统响应慢或者响应失败,通常我们会采用启动即初始化的方式。

2)连接数目:权衡所需的连接数,连接数太少则可能造成任务处理缓慢,太多不但使任务处理慢还会过度消耗系统资源。

3)连接取出:当连接池已经无可用连接时,是一直等待直到有可用连接还是分配一个新的临时连接。

4)连接放入:当连接使用完毕且连接池未满时,将连接放入连接池(包括 3 中创建的临时连接),否则关闭。

5)连接检测:长时间空闲连接和失效连接需要关闭并从连接池移除。常用的检测方法有:使用时检测和定期检测。

4.4 对象池

严格来说,各种池都是对象池模式的应用,包括前面的这三哥们。对象池跟各种池一样,也是缓存一些对象从而避免大量创建同一个类型的对象,同时限制了实例的个数。如 redis 中 0-9999 整数对象就通过采用对象池进行共享。在游戏开发中对象池模式经常使用,如进入地图时怪物和 NPC 的出现并不是每次都是重新创建,而是从对象池中取出。

5 并发化

5.1 请求并发

如果一个任务需要处理多个子任务,可以将没有依赖关系的子任务并发化,这种场景在后台开发很常见。如一个请求需要查询 3 个数据,分别耗时 T1、T2、T3,如果串行调用总耗时 T=T1+T2+T3。对三个任务执行并发,总耗时 T=max(T1,T 2,T3)。同理,写操作也如此。对于同种请求,还可以同时进行批量合并,减少 RPC 调用次数。

5.2 冗余请求

冗余请求指的是同时向后端服务发送多个同样的请求,谁响应快就是使用谁,其他的则丢弃。这种策略缩短了客户端的等待时间,但也使整个系统调用量猛增,一般适用于初始化或者请求少的场景。公司 WNS 的跑马模块其实就是这种机制,跑马模块为了快速建立长连接同时向后台多个 ip/port 发起请求,谁快就用谁,这在弱网的移动设备上特别有用,如果使用等待超时再重试的机制,无疑将大大增加用户的等待时间。

6 异步化

对于处理耗时的任务,如果采用同步等待的方式,会严重降低系统的吞吐量,可以通过异步化进行解决。异步在不同层面概念是有一些差异的,在这里我们不讨论异步 I/O。

6.1 调用异步化

在进行一个耗时的 RPC 调用或者任务处理时,常用的异步化方式如下:

  • Callback:异步回调通过注册一个回调函数,然后发起异步任务,当任务执行完毕时会回调用户注册的回调函数,从而减少调用端等待时间。这种方式会造成代码分散难以维护,定位问题也相对困难。
  • Future:当用户提交一个任务时会立刻先返回一个 Future,然后任务异步执行,后续可以通过 Future 获取执行结果。对 1.4.1 中请求并发,我们可以使用 Future 实现,伪代码如下: // 异步并发任务 Future f1 = Executor.submit(query1); Future f2 = Executor.submit(query2); Future f3 = Executor.submit(query3); // 处理其他事情 doSomething(); // 获取结果 Response res1 = f1.getResult(); Response res2 = f2.getResult(); Response res3 = f3.getResult();
  • CPS (Continuation-passing style)可以对多个异步编程进行编排,组成更复杂的异步处理,并以同步的代码调用形式实现异步效果。CPS 将后续的处理逻辑当作参数传递给 Then 并可以最终捕获异常,解决了异步回调代码散乱和异常跟踪难的问题。Java 中的 CompletableFuture 和 C++ PPL 基本支持这一特性。典型的调用形式如下: void handleRequest(const Request &req) { return req.Read().Then([](Buffer &inbuf){ return handleData(inbuf); }).Then([](Buffer &outbuf){ return handleWrite(outbuf); }).Finally(){ return cleanUp(); }); }

6.2 流程异步化

一个业务流程往往伴随着调用链路长、后置依赖多等特点,这会同时降低系统的可用性和并发处理能力。可以采用对非关键依赖进行异步化解决。如企鹅电竞开播服务,除了开播写节目存储以外,还需要将节目信息同步到神盾推荐平台、App 首页和二级页等。由于同步到外部都不是开播的关键逻辑且对一致性要求不是很高,可以对这些后置的同步操作进行异步化,写完存储即向 App 返回响应,如下图所示:

https://pic2.zhimg.com/v2-723af002c596498bb611f8c8bed59f65_r.jpg?source=1940ef5c

7 缓存

从单核 CPU 到分布式系统,从前端到后台,缓存无处不在。古有朱元璋 “缓称王” 而终得天下,今有不论是芯片制造商还是互联网公司都同样采取了“缓称王”(缓存称王)的政策才能占据一席之地。缓存是原始数据的一个复制集,其本质就是空间换时间,主要是为了解决高并发读。

7.1 缓存的使用场景

缓存是空间换时间的艺术,使用缓存能提高系统的性能。“劲酒虽好,可不要贪杯”,使用缓存的目的是为了提高性价比,而不是一上来就为了所谓的提高性能不计成本的使用缓存,而是要看场景。

适合使用缓存的场景,以之前参与过的项目企鹅电竞为例:

1)一旦生成后基本不会变化的数据:如企鹅电竞的游戏列表,在后台创建一个游戏之后基本很少变化,可直接缓存整个游戏列表;

2)读密集型或存在热点的数据:典型的就是各种 App 的首页,如企鹅电竞首页直播列表;

3)计算代价大的数据:如企鹅电竞的 Top 热榜视频,如 7 天榜在每天凌晨根据各种指标计算好之后缓存排序列表;

4)千人一面的数据:同样是企鹅电竞的 Top 热榜视频,除了缓存的整个排序列表,同时直接在进程内按页缓存了前 N 页数据组装后的最终回包结果;

不适合使用缓存的场景:

1)写多读少,更新频繁

2)对数据一致性要求严格

7.2 缓存的分类

  • 进程级缓存:缓存的数据直接在进程地址空间内,这可能是访问速度最快使用最简单的缓存方式了。主要缺点是受制于进程空间大小,能缓存的数据量有限,进程重启缓存数据会丢失。一般通常用于缓存数据量不大的场景。
  • 集中式缓存:缓存的数据集中在一台机器上,如共享内存。这类缓存容量主要受制于机器内存大小,而且进程重启后数据不丢失。常用的集中式缓存中间件有单机版 redis、memcache 等。
  • 分布式缓存:缓存的数据分布在多台机器上,通常需要采用特定算法(如 Hash)进行数据分片,将海量的缓存数据均匀的分布在每个机器节点上。常用的组件有:Memcache(客户端分片)、Codis(代理分片)、Redis Cluster(集群分片)。
  • 多级缓存:指在系统中的不同层级的进行数据缓存,以提高访问效率和减少对后端存储的冲击。以下图的企鹅电竞的一个多级缓存应用,根据我们的现网统计,在第一级缓存的命中率就已经达 94%,穿透到 grocery 的请求量很小。

![](data:image/svg+xml;utf8,)

整体工作流程如下:

  • 1)请求到达首页或者直播间服务后,如果在本地缓存命中则直接返回,否则从下一级缓存核心存储进行查询并更新本地缓存;
  • 2)前端服务缓存没有命中穿透到核心存储服务,如果命中则直接返回给前端服务,没有则请求存储层 grocery 并更新缓存;
  • 3)前两级 Cache 都没有命中回源到存储层 grocery。

7.3 缓存的模式

关于缓存的使用,已经有人总结出了一些模式,主要分为 Cache-Aside 和 Cache-As-SoR 两类。其中 SoR(system-of-record):表示记录系统,即数据源,而 Cache 正是 SoR 的复制集。

Cache-Aside:旁路缓存,这应该是最常见的缓存模式了。对于读,首先从缓存读取数据,如果没有命中则回源 SoR 读取并更新缓存。对于写操作,先写 SoR,再写缓存。这种模式架构图如下:

![](data:image/svg+xml;utf8,)

逻辑代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//读操作
data = Cache.get(key);
if(data == NULL)
{
    data = SoR.load(key);
    Cache.set(key, data);
}

//写操作
if(SoR.save(key, data))
{
    Cache.set(key, data);
}

这种模式用起来简单,但对应用层不透明,需要业务代码完成读写逻辑。同时对于写来说,写数据源和写缓存不是一个原子操作,可能出现以下情况导致两者数据不一致:

1)在并发写时,可能出现数据不一致。如下图所示,user1 和 user2 几乎同时进行读写。在 t1 时刻 user1 写 db,t2 时刻 user2 写 db,紧接着在 t3 时刻 user2 写缓存,t4 时刻 user1 写缓存。这种情况导致 db 是 user2 的数据,缓存是 user1 的数据,两者不一致。

https://pic2.zhimg.com/v2-8966ac900b460c7e88833c49e8e62a5c_r.jpg?source=1940ef5c

2)先写数据源成功,但是接着写缓存失败,两者数据不一致。 对于这两种情况如果业务不能忍受,可简单的通过先 delete 缓存然后再写 db 解决,其代价就是下一次读请求的 cache miss。

Cache-As-SoR:缓存即数据源,该模式把 Cache 当作 SoR,所以读写操作都是针对 Cache,然后 Cache 再将读写操作委托给 SoR,即 Cache 是一个代理。如下图所示:

https://pic1.zhimg.com/v2-e1dc985958daef8c20a913a4374e80ab_r.jpg?source=1940ef5c

Cache-As-SoR 有三种实现: 1)Read-Through:发生读操作时,首先查询 Cache,如果不命中则再由 Cache 回源到 SoR 即存储端实现 Cache-Aside 而不是业务)。

2)Write-Through:称为穿透写模式,由业务先调用写操作,然后由 Cache 负责写缓存和 SoR。

3)Write-Behind:称为回写模式,发生写操作时业务只更新缓存并立即返回,然后异步写 SoR,这样可以利用合并写 / 批量写提高性能。

7.4 缓存的回收策略

在空间有限、低频热点访问或者无主动更新通知的情况下,需要对缓存数据进行回收,常用的回收策略有以下几种:

1)基于时间:基于时间的策略主要可以分两种:

  • 基于 TTL(Time To Live):即存活期,从缓存数据创建开始到指定的过期时间段,不管有没有访问缓存都会过期。如 redis 的 EXPIRE。
  • 基于 TTI(Time To Idle):即空闲期,缓存在指定的时间没有被访问将会被回收。

2)基于空间:缓存设置了存储空间上限,当达到上限时按照一定的策略移除数据。

3)基于容量:缓存设置了存储条目上限,当达到上限时按照一定的策略移除数据。

4)基于引用:基于引用计数或者强弱引用的一些策略进行回收。

缓存的常见回收算法如下:

  • FIFO(First In First Out):先进选出原则,先进入缓存的数据先被移除。
  • LRU(Least Recently Used):最基于局部性原理,即如果数据最近被使用,那么它在未来也极有可能被使用,反之,如果数据很久未使用,那么未来被使用的概率也较。
  • LFU:(Least Frequently Used):最近最少被使用的数据最先被淘汰,即统计每个对象的使用次数,当需要淘汰时,选择被使用次数最少的淘汰。

7.5 缓存的崩溃与修复

由于在设计不足、请求攻击(并不一定是恶意攻击)等会造成一些缓存问题,下面列出了常见的缓存问题和解决方案。

缓存穿透:大量使用不存在的 key 进行查询时,缓存没有命中,这些请求都穿透到后端的存储,最终导致后端存储压力过大甚至被压垮。这种情况原因一般是存储中数据不存在,主要有两个解决办法。

  • 1)设置空置或默认值:如果存储中没有数据,则设置一个空置或者默认值缓存起来,这样下次请求时就不会穿透到后端存储。但这种情况如果遇到恶意攻击,不断的伪造不同的 key 来查询时并不能很好的应对,这时候需要引入一些安全策略对请求进行过滤。
  • 2)布隆过滤器:采用布隆过滤器将,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层数据库的查询压力。

缓存雪崩:指大量的缓存在某一段时间内集体失效,导致后端存储负载瞬间升高甚至被压垮。通常是以下原因造成:

  • 1)缓存失效时间集中在某段时间,对于这种情况可以采取对不同的 key 使用不同的过期时间,在原来基础失效时间的基础上再加上不同的随机时间;
  • 2)采用取模机制的某缓存实例宕机,这种情况移除故障实例后会导致大量的缓存不命中。有两种解决方案:① 采取主从备份,主节点故障时直接将从实例替换主;② 使用一致性哈希替代取模,这样即使有实例崩溃也只是少部分缓存不命中。

缓存热点:虽然缓存系统本身性能很高,但也架不住某些热点数据的高并发访问从而造成缓存服务本身过载。假设一下微博以用户 id 作为哈希 key,突然有一天志玲姐姐宣布结婚了,如果她的微博内容按照用户 id 缓存在某个节点上,当她的万千粉丝查看她的微博时必然会压垮这个缓存节点,因为这个 key 太热了。这种情况可以通过生成多份缓存到不同节点上,每份缓存的内容一样,减轻单个节点访问的压力。

7.6 缓存的一些好实践

1)动静分离:对于一个缓存对象,可能分为很多种属性,这些属性中有的是静态的,有的是动态的。在缓存的时候最好采用动静分离的方式。如企鹅电竞的视频详情分为标题、时长、清晰度、封面 URL、点赞数、评论数等,其中标题、时长等属于静态属性,基本不会改变,而点赞数、评论数经常改变,在缓存时这两部分开,以免因为动态属性每次的变更要把整个视频缓存拉出来进行更新一遍,成本很高。

2)慎用大对象:如果缓存对象过大,每次读写开销非常大并且可能会卡住其他请求,特别是在 redis 这种单线程的架构中。典型的情况是将一堆列表挂在某个 value 的字段上或者存储一个没有边界的列表,这种情况下需要重新设计数据结构或者分割 value 再由客户端聚合。

3)过期设置:尽量设置过期时间减少脏数据和存储占用,但要注意过期时间不能集中在某个时间段。

4)超时设置:缓存作为加速数据访问的手段,通常需要设置超时时间而且超时时间不能过长(如 100ms 左右),否则会导致整个请求超时连回源访问的机会都没有。

5)缓存隔离:首先,不同的业务使用不同的 key,防止出现冲突或者互相覆盖。其次,核心和非核心业务进行通过不同的缓存实例进行物理上的隔离。

6)失败降级:使用缓存需要有一定的降级预案,缓存通常不是关键逻辑,特别是对于核心服务,如果缓存部分失效或者失败,应该继续回源处理,不应该直接中断返回。

7)容量控制:使用缓存要进行容量控制,特别是本地缓存,缓存数量太多内存紧张时会频繁的 swap 存储空间或 GC 操作,从而降低响应速度。

8)业务导向:以业务为导向,不要为了缓存而缓存。对性能要求不高或请求量不大,分布式缓存甚至数据库都足以应对时,就不需要增加本地缓存,否则可能因为引入数据节点复制和幂等处理逻辑反而得不偿失。

9)监控告警:跟妹纸永远是对的一样,总不会错。对大对象、慢查询、内存占用等进行监控。

8 分片

分片即将一个较大的部分分成多个较小的部分,在这里我们分为数据分片和任务分片。对于数据分片,在本文将不同系统的拆分技术术语(如 region、shard、vnode、partition)等统称为分片。分片可以说是一箭三雕的技术,将一个大数据集分散在更多节点上,单点的读写负载随之也分散到了多个节点上,同时还提高了扩展性和可用性。

数据分片,小到编程语言标准库里的集合,大到分布式中间件,无所不在。如我曾经写过一个线程安全的容器以放置各种对象时,为了减少锁争用,对容器进行了分段,每个分段一个锁,按照哈希或者取模将对象放置到某个分段中,如 Java 中的 ConcurrentHashMap 也采取了分段的机制。分布式消息中间件 Kafka 中对 topic 也分成了多个 partition,每个 partition 互相独立可以比并发读写。

8.1 分片策略

进行分片时,要尽量均匀的将数据分布在所有节点上以平摊负载。如果分布不均,会导致倾斜使得整个系统性能的下降。常见的分片策略如下:

  • 区间分片 基于一段连续关键字的分片,保持了排序,适合进行范围查找,减少了垮分片读写。区间分片的缺点是容易造成数据分布不均匀,导致热点。如直播平台,如果按 ID 进行区间分片,通常短位 ID 都是一些大主播,如在 100-1000 内 ID 的访问肯定比十位以上 ID 频繁。常见的还有按时间范围分片,则最近时间段的读写操作通常比很久之前的时间段频繁。

https://pica.zhimg.com/v2-639fc5e27b2930116bf17338edb2521a_r.jpg?source=1940ef5c

  • 随机分片 按照一定的方式(如哈希取模)进行分片,这种方式数据分布比较均匀,不容易出现热点和并发瓶颈。缺点就是失去了有序相邻的特性,如进行范围查询时会向多个节点发起请求。

https://pic1.zhimg.com/v2-3cdd359a52abadd2495da49232c90ffe_r.jpg?source=1940ef5c

  • 组合分片:对区间分片和随机分片的一种折中,采取了两种方式的组合。通过多个键组成复合键,其中第一个键用于做哈希随机,其余键用于进行区间排序。如直播平台以主播 id + 开播时间(anchor_id,live_time)作为组合键,那么可以高效的查询某主播在某个时间段内的开播记录。社交场景,如微信朋友圈、QQ 说说、微博等以用户 id + 发布时间 (user_id,pub_time) 的组合找到用户某段时间的发表记录。

8.2 二级索引

二级索引通常用来加速特定值的查找,不能唯一标识一条记录,使用二级索引需要二次查找。关系型数据库和一些 K-V 数据库都支持二级索引,如 mysql 中的辅助索引(非聚簇索引),ES 倒排索引通过 term 找到文档。

  • 本地索引 索引存储在与关键字相同的分区中,即索引和记录在同一个分区,这样对于写操作时都在一个分区里进行,不需要跨分区操作。但是对于读操作,需要聚合其他分区上的数据。如以王者荣耀短视频为例,以视频 vid 作为关键索引,视频标签(如五杀、三杀、李白、阿珂)作为二级索引,本地索引如下图所示:

https://pic2.zhimg.com/v2-84bbf4ecc7b099f94a6473f4e870b8ab_r.jpg?source=1940ef5c

  • 全局索引 按索引值本身进行分区,与关键字所以独立。这样对于读取某个索引的数据时,都在一个分区里进行,而对于写操作,需要跨多个分区。仍以上面的例子为例,全局索引如下图所示:

https://pica.zhimg.com/v2-776969a314c5a4757ef6c830c7dbc779_r.jpg?source=1940ef5c

8.3 路由策略

路由策略决定如何将数据请求发送到指定的节点,包括分片调整后的路由。通常有三种方式:客户端路由、代理路由和集群路由。

  • 客户端路由 客户端直接操作分片逻辑,感知分片和节点的分配关系并直接连接到目标节点。Memcache 就是采用这种方式实现的分布式,如下图所示。

https://pica.zhimg.com/v2-9a64ae3648a895f6ad719aa62b5e4bfc_r.jpg?source=1940ef5c

  • 代理层路由 客户端的请求到发送到代理层,由其将请求转发到对应的数据节点上。很多分布式系统都采取了这种方式,如业界的基于 redis 实现的分布式存储 codis(codis-proxy 层),公司内如 CMEM(Access 接入层)、DCache(Proxy+Router)等。如下图所示 CMEM 架构图,红色方框内的 Access 层就是路由代理层。

https://pica.zhimg.com/v2-9247a47f92cf8eccd30b08857e82bbb6_r.jpg?source=1940ef5c

  • 集群路由 由集群实现分片路由,客户端连接任意节点,如果该节点存在请求的分片,则处理;否则将请求转发到合适的节点或者告诉客户端重定向到目标节点。如 redis cluster 和公司的 CKV + 采用了这种方式,下图的 CKV + 集群路由转发。

https://pic3.zhimg.com/v2-2de420f3380e4010ce19210daae840ce_r.jpg?source=1940ef5c

以上三种路由方式都各优缺点,客户端路由实现相对简单但对业务入侵较强。代理层路由对业务透明,但增加了一层网络传输,对性能有一定影响,同时在部署维护上也相对复杂。集群路由对业务透明,且比代理路由少了一层结构,节约成本,但实现更复杂,且不合理的策略会增加多次网络传输。

8.4 动态平衡

在学习平衡二叉树和红黑树的时候我们都知道,由于数据的插入删除会破坏其平衡性。为了保持树的平衡,在插入删除后我们会通过左旋右旋动态调整树的高度以保持再平衡。在分布式数据存储也同样需要再平衡,只不过引起不平衡的因素更多了,主要有以下几个方面:

1)读写负载增加,需要更多 CPU;

2)数据规模增加,需要更多磁盘和内存;

3)数据节点故障,需要其他节点接替;

业界和公司很多产品也都支持动态平衡调整,如 redis cluster 的 resharding,HDFS/kafka 的 rebalance。常见的方式如下:

  • 固定分区 创建远超节点数的分区数,为每个节点分配多个分区。如果新增节点,可从现有的节点上均匀移走几个分区从而达到平衡,删除节点反之,如下图所示。典型的就是一致性哈希,创建 2^32-1 个虚拟节点(vnode)分布到物理节点上。该模式比较简单,需要在创建的时候就确定分区数,如果设置太小,数据迅速膨胀的话再平衡的代价就很大。如果分区数设置很大,则会有一定的管理开销。

https://pic1.zhimg.com/v2-a11ebae0973cf5770c0366c64a0d7549_r.jpg?source=1940ef5c

  • 动态分区 自动增减分区数,当分区数据增长到一定阀值时,则对分区进行拆分。当分区数据缩小到一定阀值时,对分区进行合并。类似于 B + 树的分裂删除操作。很多存储组件都采用了这种方式,如 HBase Region 的拆分合并,TDSQL 的 Set Shard。这种方式的优点是自动适配数据量,扩展性好。使用这种分区需要注意的一点,如果初始化分区为一个,刚上线请求量就很大的话会造成单点负载高,通常采取预先初始化多个分区的方式解决,如 HBase 的预分裂。

8.5 分库分表

当数据库的单表 / 单机数据量很大时,会造成性能瓶颈,为了分散数据库的压力,提高读写性能,需要采取分而治之的策略进行分库分表。通常,在以下情况下需要进行分库分表:

1)单表的数据量达到了一定的量级(如 mysql 一般为千万级),读写的性能会下降。这时索引也会很大,性能不佳,需要分解单表。

2)数据库吞吐量达到瓶颈,需要增加更多数据库实例来分担数据读写压力。

分库分表按照特定的条件将数据分散到多个数据库和表中,分为垂直切分和水平切分两种模式。

  • 垂直切分:按照一定规则,如业务或模块类型,将一个数据库中的多个表分布到不同的数据库上。以直播平台为例,将直播节目数据、视频点播数据、用户关注数据分别存储在不同的数据库上,如下图所示:

![](data:image/svg+xml;utf8,)

优点:

1)切分规则清晰,业务划分明确;

2)可以按照业务的类型、重要程度进行成本管理,扩展也方便;

3)数据维护简单;

缺点:

1)不同表分到了不同的库中,无法使用表连接 Join。不过在实际的业务设计中,也基本不会用到 join 操作,一般都会建立映射表通过两次查询或者写时构造好数据存到性能更高的存储系统中。

2)事务处理复杂,原本在事务中操作同一个库的不同表不再支持。如直播结束时更新直播节目同时生成一个直播的点播回放在分库之后就不能在一个事物中完成,这时可以采用柔性事务或者其他分布式事物方案。

  • 水平切分:按照一定规则,如哈希或取模,将同一个表中的数据拆分到多个数据库上。可以简单理解为按行拆分,拆分后的表结构是一样的。如直播系统的开播记录,日积月累,表会越来越大,可以按照主播 id 或者开播日期进行水平切分,存储到不同的数据库实例中。 优点: 1)切分后表结构一样,业务代码不需要改动; 2)能控制单表数据量,有利于性能提升; 缺点: 1)Join、count、记录合并、排序、分页等问题需要跨节点处理; 2)相对复杂,需要实现路由策略; 综上所述,垂直切分和水平切分各有优缺点,通常情况下这两种模式会一起使用。

8.6 任务分片

记得小时候发新书,老师抱了一堆堆的新书到教室,然后找几个同学一起分发下去,有的发语文,有的发数学,有的发自然,这就是一种任务分片。车间中的流水线,经过每道工序的并行后最终合成最终的产品,也是一种任务分片。

任务分片将一个任务分成多个子任务并行处理,加速任务的执行,通常涉及到数据分片,如归并排序首先将数据分成多个子序列,先对每个子序列排序,最终合成一个有序序列。在大数据处理中,Map/Reduce 就是数据分片和任务分片的经典结合。

9 存储

任何一个系统,从单核 CPU 到分布式,从前端到后台,要实现各式各样的功能和逻辑,只有读和写两种操作。而每个系统的业务特性可能都不一样,有的侧重读、有的侧重写,有的两者兼备,本节主要探讨在不同业务场景下存储读写的一些方法论。

9.1 读写分离

大多数业务都是读多写少,为了提高系统处理能力,可以采用读写分离的方式将主节点用于写,从节点用于读,如下图所示。

![](data:image/svg+xml;utf8,)

读写分离架构有以下几个特点: 1)数据库服务为主从架构,可以为一主一从或者一主多从; 2)主节点负责写操作,从节点负责读操作; 3)主节点将数据复制到从节点; 基于基本架构,可以变种出多种读写分离的架构,如主 - 主 - 从、主 - 从 - 从。主从节点也可以是不同的存储,如 mysql+redis。

读写分离的主从架构一般采用异步复制,会存在数据复制延迟的问题,适用于对数据一致性要求不高的业务。可采用以下几个方式尽量避免复制滞后带来的问题。

1)写后读一致性:即读自己的写,适用于用户写操作后要求实时看到更新。典型的场景是,用户注册账号或者修改账户密码后,紧接着登录,此时如果读请求发送到从节点,由于数据可能还没同步完成,用户登录失败,这是不可接受的。针对这种情况,可以将自己的读请求发送到主节点上,查看其他用户信息的请求依然发送到从节点。

2)二次读取:优先读取从节点,如果读取失败或者跟踪的更新时间小于某个阀值,则再从主节点读取。

3)关键业务读写主节点,非关键业务读写分离。

4)单调读:保证用户的读请求都发到同一个从节点,避免出现回滚的现象。如用户在 M 主节点更新信息后,数据很快同步到了从节点 S1,用户查询时请求发往 S1,看到了更新的信息。接着用户再一次查询,此时请求发到数据同步没有完成的从节点 S2,用户看到的现象是刚才的更新的信息又消失了,即以为数据回滚了。

9.2 动静分离

动静分离将经常更新的数据和更新频率低的数据进行分离。最常见于 CDN,一个网页通常分为静态资源(图片 / js/css 等)和动态资源(JSP、PHP 等),采取动静分离的方式将静态资源缓存在 CDN 边缘节点上,只需请求动态资源即可,减少网络传输和服务负载。

在数据库和 KV 存储上也可以采取动态分离的方式,如 7.6 提到的点播视频缓存的动静分离。在数据库中,动静分离更像是一种垂直切分,将动态和静态的字段分别存储在不同的库表中,减小数据库锁的粒度,同时可以分配不同的数据库资源来合理提升利用率。

9.3 冷热分离

冷热分离可以说是每个存储产品和海量业务的必备功能,Mysql、ElasticSearch、CMEM、Grocery 等都直接或间接支持冷热分离。将热数据放到性能更好的存储设备上,冷数据下沉到廉价的磁盘,从而节约成本。企鹅电竞为了节省在腾讯云成本,直播回放按照主播粉丝数和时间等条件也采用了冷热分离,下图是 ES 冷热分离的一个实现架构图。

https://pica.zhimg.com/v2-23176ff9f60ebfe6606a3e1b414c9450_r.jpg?source=1940ef5c

9.4 重写轻读

重写轻度个人理解可能有两个含义: 1)关键写,降低读的关键性,如异步复制,保证主节点写成功即可,从节点的读可容忍同步延迟。 2)写重逻辑,读轻逻辑,将计算的逻辑从读转移到写。适用于读请求的时候还要进行计算的场景,常见的如排行榜是在写的时候构建而不是在读请求的时候再构建。

在微博、朋友圈等社交产品场景中都有类似关注或朋友的功能。以朋友圈模拟为例(具体我也不知道朋友圈是怎么做的),如果用户进入朋友圈时看到的朋友消息列表是在请求的时候遍历其朋友的新消息再按时间排序组装出来的,这显然很难满足朋友圈这么大的海量请求。可以采取重写轻读的方式,在发朋友圈的时候就把列表构造好,然后直接读就可以了。

仿照 Actor 模型,为用户建立一个信箱,用户发朋友圈后写完自己的信箱就返回,然后异步的将消息推送到其朋友的信箱,这样朋友读取他的信箱时就是其朋友圈的消息列表,如下图所示:

https://pic4.zhimg.com/v2-0b8eb0ca5386fc2d47fea2d11630d1b3_r.jpg?source=1940ef5c

上图仅仅是为了展示重写轻度的思路,在实际应用中还有些其他问题。如: 1)写扩散:这是个写扩散的行为,如果一个大户的朋友很多,这写扩散的代价也是很大的,而且可能有些人万年不看朋友圈甚至屏蔽了朋友。需要采取一些其他的策略,如朋友数在某个范围内是才采取这种方式,数量太多采取推拉结合和分析一些活跃指标等。 2)信箱容量:一般来说查看朋友圈不会不断的往下翻页查看,这时候应该限制信箱存储条目数,超出的条目从其他存储查询。

9.5 数据异构

数据异构主要是按照不同的维度建立索引关系以加速查询。如京东、天猫等网上商城,一般按照订单号进行了分库分表。由于订单号不在同一个表中,要查询一个买家或者商家的订单列表,就需要查询所有分库然后进行数据聚合。可以采取构建异构索引,在生成订单的时同时创建买家和商家到订单的索引表,这个表可以按照用户 id 进行分库分表。

10 队列

在系统应用中,不是所有的任务和请求必须实时处理,很多时候数据也不需要强一致性而只需保持最终一致性,有时候我们也不需要知道系统模块间的依赖,在这些场景下队列技术大有可为。

10.1 应用场景

队列的应用场景很广泛,总结起来主要有以下几个方面:

  • 异步处理:业务请求的处理流程通常很多,有些流程并不需要在本次请求中立即处理,这时就可以采用异步处理。如直播平台中,主播开播后需要给粉丝发送开播通知,可以将开播事件写入到消息队列中,然后由专门的 daemon 来处理发送开播通知,从而提高开播的响应速度。
  • 流量削峰:高并发系统的性能瓶颈一般在 I/O 操作上,如读写数据库。面对突发的流量,可以使用消息队列进行排队缓冲。以企鹅电竞为例,每隔一段时间就会有大主播入驻,如梦泪等。这个时候会有大量用户的订阅主播,订阅的流程需要进行多个写操作,这时先只写用户关注了哪个主播存储。然后在进入消息队列暂存,后续再写主播被谁关注和其他存储。
  • 系统解耦:有些基础服务被很多其他服务依赖,如企鹅电竞的搜索、推荐等系统需要开播事件。而开播服务本身并不关心谁需要这些数据,只需处理开播的事情就行了,依赖服务(包括第一点说的发送开播通知的 daemon)可以订阅开播事件的消息队列进行解耦。
  • 数据同步:消息队列可以起到数据总线的作用,特别是在跨系统进行数据同步时。拿我以前参与过开发的一个分布式缓存系统为例,通过 RabbitMQ 在写 Mysql 时将数据同步到 Redis,从而实现一个最终一致性的分布式缓存。
  • 柔性事务:传统的分布式事务采用两阶段协议或者其优化变种实现,当事务执行时都需要争抢锁资源和等待,在高并发场景下会严重降低系统的性能和吞吐量,甚至出现死锁。互联网的核心是高并发和高可用,一般将传统的事务问题转换为柔性事务。下图是阿里基于消息队列的一种分布式事务实现(详情查看:企业 IT 架构转型之道 阿里巴巴中台战略思想与架构实战,微信读书有电子版):

https://pic1.zhimg.com/v2-031ea9e216bd3d30ee5b986dcbbd3ba8_r.jpg?source=1940ef5c

其核心原理和流程是:

1)分布式事务发起方在执行第一个本地事务前,向 MQ 发送一条事务消息并保存到服务端,MQ 消费者无法感知和消费该消息 ①②。

2)事务消息发送成功后开始进行单机事务操作 ③:

a)如果本地事务执行成功,则将 MQ 服务端的事务消息更新为正常状态 ④;

b)如果本地事务执行时因为宕机或者网络问题没有及时向 MQ 服务端反馈,则之前的事务消息会一直保存在 MQ。MQ 服务端会对事务消息进行定期扫描,如果发现有消息保存时间超过了一定的时间阀值,则向 MQ 生产端发送检查事务执行状态的请求 ⑤;

c)检查本地事务结果后 ⑥,如果事务执行成功,则将之前保存的事务消息更新为正常状态,否则告知 MQ 服务端进行丢弃;

3)消费者获取到事务消息设置为正常状态后,则执行第二个本地事务 ⑧。如果执行失败则通知 MQ 发送方对第一个本地事务进行回滚或正向补偿。

10.2 应用分类

  • 缓冲队列:队列的基本功能就是缓冲排队,如 TCP 的发送缓冲区,网络框架通常还会再加上应用层的缓冲区。使用缓冲队列应对突发流量时,使处理更加平滑,从而保护系统,上过 12306 买票的都懂。

https://pic2.zhimg.com/v2-83edfbe94cee61178e83c1a97cdcc392_r.jpg?source=1940ef5c

在大数据日志系统中,通常需要在日志采集系统和日志解析系统之间增加日志缓冲队列,以防止解析系统高负载时阻塞采集系统甚至造成日志丢弃,同时便于各自升级维护。下图天机阁数据采集系统中,就采用 Kafka 作为日志缓冲队列。

https://pic1.zhimg.com/v2-5867b520bfea6571fa9167b24bdda826_r.jpg?source=1940ef5c

  • 请求队列:对用户的请求进行排队,网络框架一般都有请求队列,如 spp 在 proxy 进程和 work 进程之间有共享内存队列,taf 在网络线程和 Servant 线程之间也有队列,主要用于流量控制、过载保护和超时丢弃等。

https://pic1.zhimg.com/v2-15cd2e2378508d16e2adf0d9f85bac36_r.jpg?source=1940ef5c

  • 任务队列:将任务提交到队列中异步执行,最常见的就是线程池的任务队列。
  • 消息队列 用于消息投递,主要有点对点和发布订阅两种模式,常见的有 RabbitMQ、RocketMQ、Kafka 等,下图是常用消息队列的对比:

https://pic3.zhimg.com/v2-0ae0c792535370a0bdf0660123a22bf0_r.jpg?source=1940ef5c

总结

本文探讨和总结了后台开发设计高性能服务的常用方法和技术,并通过思维导图总结了成一套方法论。当然这不是高性能的全部,甚至只是凤毛菱角。每个具体的领域都有自己的高性能之道,如网络编程的 I/O 模型和 C10K 问题,业务逻辑的数据结构和算法设计,各种中间件的参数调优等。文中也描述了一些项目的实践,如有不合理的地方或者有更好的解决方案,请各位同仁赐教。

想了解鹅厂技术,欢迎关注

@腾讯技术工程 。