作者 | Pamela Canchanya
译者 | 核子可乐
策划 | 小智
电商网站通常是互联网架构创新的前沿阵地,在美国有亚马逊,在中国有阿里巴巴。本篇文章则是欧洲最大在线零售平台 Zalando 在业务规模化以后的架构迁移、技术构建方面的经验。
1 写在前面
Zalando 是欧洲目前规模最大的在线零售平台,我们与其他竞争对手的主要区别在于,我们在欧洲的大部分国家和地区提供免费送货、100 天退货以及便捷的免费退款服务。
Zalando 公司在欧洲的 17 个国家与地区开展业务,网站每月访问量超过 2.5 亿,活跃客户超过 2600 万。目前,公司已经拥有 1 万 5 千多名员工,去年收入约为 54 亿欧元。我们也在进一步推动规模扩展,希望为即将到来的黑色星期五购物季做好准备。
去年的黑五,我们打破了销售额历史纪录,共收到约 200 万份订单。在峰值时段,我们每分钟接到超过 4200 份订单,这不只代表着可观的收入,同时也是一种巨大的挑战。如果没有可靠的底层技术作为支持,这一切根本不可能实现。为了提早做好准备,我们从 2015 年就开始着手从单体架构迁移至微服务架构。到 2019 年,我们已经拥有超过 1000 种微服务。我们目前的技术部门拥有 1000 多名开发人员,编组成超过 200 个具体团队。各个团队立足自身职能涵盖一部分客户体验与业务事务。此外,各个团队也拥有具备跨学科技能的不同团队成员,负责满足前端、后端、数据科学、用户体验、研究、产品以及团队需要满足的一切客户需求。
由于业务规模非常可观,我们对于各支团队需要管理的服务也肩负着端到端责任。当然,随着从单体到微服务的迁移过渡,得到授权的各个团队也开始自主管理下辖的软件开发工作。在实施过程中,我们发现让各个团队完全按照自己的方式执行任务其实相当困难,所以我们最终制定出软件开发标准流程。流程的确立,离不开开发者生产团队为我们提供的工具方案。如此一来,在各个软件开发周期当中,每支团队都能轻松启动新的项目、着手设置,并逐步完成编码、构建、测试、部署以及监控等环节。
2 结账功能与实施架构
我专门负责结账功能的开发,很多不熟悉的朋友可能觉得结账这东西没什么大不了,但它可是顾客购物体验中的最后一环,甚至是最关键的一环。我们需要告知客户如何配送、配送到哪里、如何结算、以及怎样完成下单。在团队当中,每名成员都需要打理多项微服务。当然,不同的成员有着不同的开发风格,我们的微服务也有 Java、Scala 以及 Node JS 等多种形式。我们的主要通信对象是 REST,同时也通过消息传递机制与某些依赖项进行通信。对于稳定的应用场景,我们利用 Cassandra 作为数据存储方案。在配置方面,我们则选择 ETCD 来实现。
我们的所有微服务都运行在 AWS 以及 Kubernetes 当中。当初从单体式架构迁移至微服务架构的同时,我们也顺带完成了云迁移。EC2 实例是我们最早体验的云计算服务。最近两年以来,我们开始慢慢接触 Kubernetes。我的团队在这方面贡献了巨大的力量,主要负责 AWS 与 Kubernetes 中的软件迁移与服务维护。目前,包括结账功能与 Lambda 在内的各项微服务都运行在容器环境当中。所有微服务环境都限定在我们的基础设施之内。对于面向客户的应用,我们使用 React。当然,我们也用到了多种其他技术。
总而言之,我们开发出一项相当稳定的结账服务,运行效果不错。它符合一切结账功能需要遵循的规则,并能够与其他 Zalando 依赖项进行交互,同时采用 Cassandra 作为数据存储。接下来,我们还为该后端开发出了相应的前端。这同样是一项微服务,用于汇总来自结账服务及其他无法在结账功能之内获取的 Zalando 服务的数据。例如,如果顾客在结账当中很可能希望再次查看商品的相关信息,这部分内容显然不属于结账数据,因此我们必须与其他微服务进行通信。
接下来,我们还有前端微服务。前端微服务负责提供服务器端片段呈现结果。片段属于页面的一部分,类似于页眉、正文、内容以及页脚。顾客在当前页面上看到的虽然是一个整体,但其中各个部分很可能来自不同的开发团队。在完成所有的片段开发之后,我们将提供 Tailor 服务,用于将这些片段组合起来。顺带一提,Zalando 已经对 Tailor 进行了开源。Skipper 是 Zalando 开发完成的另一个项目,作为我们的开源 HTTP 路由。通过这种方式,我们可以将所有页面都存储在 Zalando 在线时尚品销售平台当中。
所有这些组件都与“结账”这一核心上下文相关,而且都对我们的业务拥有至关重要的意义。结账是客户体验流程中非常重要的组成部分,甚至直接影响着我们的客户感受与业绩结果。在 Zalando,我们拥有大量微服务。如果其中某项微服务遭遇崩溃,并导致结账操作无法正常完成,那问题就严重了。换言之,只要结账功能出了问题,全公司马上就会跑来向我们兴师问罪……
3 结账的挑战与经验教训
正如前文所提到,利用微服务生态系统构建结账功能,最主要的挑战在于大量微服务之间的彼此交互。此外,我们还需要与其他 Zalando 微服务保持交互。这无疑显著增加了可能的故障点,同时带来大量接触点组合——这些接触点同样可能引发问题。另外,我们只掌握着一部分微服务,但却无法控制其他依赖项的变更。因此一旦某些依赖关系受到影响,我们的结账功能也有可能迅速陷入瘫痪。
我们需要从可靠性模式当中总结经验教训,以避免此类故障的发生,最终确保结账功能的可用性。我们还需要审查自己的扩展方式,为高达 2600 万活跃用户提供服务,并准备迎接黑色星期五等高流量活动。我们也会审查监控记录,借此获取正确的信号,深入了解我们的服务如何运行,以及在发生问题时如何做出响应。
下面,我会以结账确认页面为例,向大家介绍如何以可靠方式构建微服务。这里是客户决定下单的最后一个环节。为了顺利完成结账操作,其中会涉及到多种微服务以及依赖项。例如,我们需要了解客户所指定的配送目的地——可能是顾客的家或者办公室地址;我们还需要提供配送服务选项,检查顾客选择了普通快递、次日达还是价格更低的四日达等服务,外加顾客所选择的商品及付款方式等等。所有这一切,都需要与结账功能进行顺利交互,而后才能完成整个结算及下单流程。只要其中一项未能正常起效,客户就没办法完成结账,并给我们的业务造成直接影响。
以配送为例,配送服务当中包含所有可用的配送选项,能够根据我们在售的商品以及客户的实际居住地进行邮费结算。如果该服务无法正常起效,客户就不能选择适合自己的配送方式,更谈不上最终结账了。为了改善这种情况,我们尝试利用一些特定的模式改善与配送服务的交互,希望避免此类错误的发生。另外,结账页面也不能太丑;而且对我个人来说,语言显示也得正确,毕竟如果跳出个德语页面,我都不知道该怎么操作。
具体的改善方法,就是重试。所谓重试,就是在某项操作(例如结账页面中的配送选项)在发生问题后,进行循环尝试的具体次数。重试也有很多方法,本身可以进行深度改进,但我们的想法就是确保在发生网络或者服务过载等瞬时错误时,通过再次操作来测试错误是否消失。如果错误消失,我们就能获得成功的响应,从而顺利完成结账流程。
最终消失的错误属于暂时性错误。另外,我们还要确保不会对所有错误进行重试,毕竟如果错误源自某项与其他服务相冲突的变更,那么单纯重试根本解决不了问题,只会让流程陷入死循环。换句话说,这些错误不会自行消失,我们要确保的是只对那些会自行消失的错误进行重试。
再来看代码示例。现在,我在尝试运行部分 Scala 代码,并努力保证应用模式匹配。如果结账页面中显示出配送选项,就证明模式匹配成功,接下来可以顺利完成下单了。如果遇到瞬间故障,页面就会重试。但如果有错误,我们会弹出错误提示,而后尝试进行解决。
尽管如此,考虑到 Zalando 的庞大规模,以上方式仍有可能引发新的问题。每一秒钟,Zalando 上都可能同时存在 1000 项请求,如果进行 3 次重试,那么请求数量就会新增 3000 项。在此期间,那些发生过载的服务也需要进行重试,这无疑会令过载问题更加严重。整个系统都会陷入死循环,而我们将无力回天。为此,我们选择在重试中引入指数积压机制。简单来讲,就是在发生故障后不立即进行重试,而是等待一段时间再重新操作。每一次重试间的间隔都将以指数形式增加,这就能大大降低远程服务全面过载的可能性。举例来说,我们的第一次重试请求间隔 100 毫秒,同样失败;接下来等待 200 毫秒,继续重试;而后等待 400 毫秒……依此类推。
但这还是无法保证万无一失。我们可能会用尽重试次数,接下来暂时性错误就自然变成了永久性错误。如果遇到这种情况,肯定不能继续允许其他请求调用这项远程服务了。为了避免这个问题,我们需要避免可能导致调用永久失败的操作。为此,我们引入了断路器模式。其中的基本思路是,我们把操作打包到一套回路当中,只要远程服务正常,就能继续发送操作。但如果服务的健康状态出现了问题(由相关指标决定),我们不再继续发送请求,并快速广播失败问题。这样总比长时间等待好得多。
这套回路最重要的机制,就是判断何时断开。阈值正是为此而生,我们选择使用 Hystrix 完整阈值。如果阈值设置为 50%,那么一旦错误率高于该阈值,则不再允许其他请求调用此项远程服务。在断路完成后,结账页面中的获取配送选项功能自动失败。但问题是,这样一来顾客仍然无法顺利完成下单,那该怎么办?应急预案需要立刻上线。这是个有趣的问题,作为开发人员,我们一直习惯于编写优雅的代码并处理其中存在的 bug——但是,有些问题可能跟代码 bug 无关,这时候应急计划就非常必要了。
当然,有些应急计划需要由专人负责规划;但好在我们拥有一支完整的团队,每位成员都可以针对结账中的组件做出良好决策。通过讨论,我们发现标准寄送是绝大多数买家的首选,而且相关服务可以保证随时可用。因此,我们决定将其设置为默认配送方式。虽然能提供多种配送选项更好,但能结账总比不能结账强得多。
综上所述,我们用指数回退的方式进行操作重试,利用断路器把操作打包起来,并在可行的情况下利用预置选项应对故障。总之,我们想尽一切办法确保异常状况不致中断顾客的结账流程。
4 微服务扩展
下一项工作的重点在于微服务架构的扩展伸缩。首先介绍一下我们服务中的典型流量模式:每天下午 4 点到午夜,访问量都会迎来高峰。接下来,人们会上床睡觉,因此流量急剧下降,直到第二天上午 7 点再次开始回升。我们需要确保服务足以应对一天中的各种流量强度,同时也随着时间推移从中总结出一些固定的模式。当然,各个国家的情况总有不同,因此也具有各异的具体模式细节。另外,促销或者推广活动期间,流量也会出现独立于常规模式之外的浮动。下面是我们在处理业务流量时采取的宏观方式。
首先,我们确保每项微服务都具有相同的基础设施,并利用负载均衡器处理传入的请求。接下来,我们会将请求分发至多个实例内的微服务副本,或者是通过多个端口将请求分发至各 Kubernetes 节点。每个实例都运行有基于 Zalando 的镜像,此镜像内包含大量需要遵循合规性与安全性要求的内容,确保整个业务流程始终符合当前运营策略。我们是一家认真的企业,自然要以认真的态度对待自己的业务。
以此为基础,我们利用容器环境运行各项微服务。这些容器环境可以是 Node 环境、JVM 环境等等。至少在我们的业务体系内,主要使用 Node 与 JVM。请求处理方式大体就是这样。至于规模伸缩问题,我们设置了两种实现方式。第一种是横向伸缩,这既是最方便的选择,也是我在实践中体会到的最简单的选择。因为我们在架构设计时就已经为每项微服务部署了三套副本以及三个对应的实例。一旦发现实例运行状态不佳的迹象(基于某些特定指标,例如 CPU 利用率达到 80%),则代表现有资源容量可能短缺,于是我们会立即启动更多实例。目前,我们仅使用 AWS 与 Kubernetes 中的自动规模伸缩策略来增加额外的实例或者端口。
第二种方法自然就是纵向伸缩了。这是一种需要根据情况适当使用的选项。当遇到某些规模扩展问题,但当前设置又无法处理更多请求时,我们就需要增加单一实例的容量上限(不同于横向扩展中的直接增加实例数量)。举例来说,我们可以添加更多 CPU、更高的内存容量;如果使用的是 AWS 实例,我们还可以根据需求切换为其他拥有更多或更少计算核心的实例类型。这两种方式各有优点与缺陷,我个人认为从长远来看,横向扩展一般更好,因为我们不需要依赖于特定的设备类型,同时升级过程也会更加轻松。
当然,可规模伸缩性也会带来负面影响。比如在黑色星期五期间,我们就要进行容量规划、业务预测、负载测试,并最终发现需要将规模扩大 10 倍。我们想得很简单,直接把原本的最小值 6 设置到了 60,就以为自动伸缩机制部署到位了。但我们没有意识到的是,当实例数量增长时,也会带来更高的数据库连接需求。换言之,2600 万活跃客户平时其实是在以较为分散的方式使用我们的网站;但现在,他们一股脑涌来,而我们增长了 10 倍的实例又会将压力进一步转嫁到指向 Cassandra 数据库的连接身上。
可怜的 Cassandra 当然无法处理所有连接。最终,巨大的流量令 Cassandra 疲于应对,CPU 占用率也开始一路飙升。而一个 Cassandra 实例的崩溃,又会很快引发下一个 Cassandra 实例的崩溃,不断引发恐怖的连锁反应。在这种情况下,即使服务规模能够快速扩展,缺少了数据存储的支持,整个微服务架构也仍然会陷入瘫痪。这让我们了解到,即使是看似简单的规模伸缩,也绝对没有那么无脑。另外,规模伸缩也会给依赖关系造成直接影响。因此,我们决定通过改进 Cassandra 中的配置,同时调整 Cassandra 内的资源处理方式来解决这个问题。很幸运,效果令人满意。
对单一微服务进行扩展不是什么大事,但对于结账这类复杂的功能系统,或者是其他包含多个微服务与依赖项的方案,一旦其中任何一项无法同步扩展,那么整个流程都有可能遭遇故障。因此,我们绝不能把各项微服务彼此割裂开来,而应将其视为完整的生态系统。
5 低流量转移
最后一个难题,就是在服务转移期间的规模伸缩问题。这一点同样非常重要,毕竟事先规划得再好,真正部署时也有可能闹出毛病。每次进行部署时,我们都强调要将实例或者端口数量控制在最低水平。举例来说,我们可能会配备 4 个实例,具体数字来自我们根据以往模式以及经验积累得出的猜测性结论。比如当前负责承载 100% 流量的服务版本 1 采用 4 个实例,那么版本 2 也会从 4 个实例起步。如此一来,我们就能够比较轻松地以百分比形式进行流量调整;而且由于版本 2 与版本 1 的容量相同,因此后者的流量可以更安全地转移到前者当中。
但对于流量较高的情况,转移过程会受到怎样的影响?由于自动扩展策略已经启动,因此版本 1 必然要进行纵向扩展。扩展之后,新的版本 1 当中包含 6 个实例;而刚刚创建的版本 2 仍然运行有 4 个实例。到这里状况就很明确了——即使逐步进行流量切换,版本 2 仍然可能出现实例过载的风险,服务可用性也有很大机率随之降低。
我们的系统中配备有负载均衡器,它会持续获取不同实例的指标以判断其是否正常工作,因此在过载之后,负载均衡器会停止发送流量。流量中断意味着返回错误,客户也就无法正常使用服务。另外还有一种可能,就是我们在微服务之内看不到任何异常,只是在各个国家 / 地区都呈现出请求速率下降的趋势。
因此,在进行转移时,请务必确保目标服务与当前服务拥有相同的容量。否则,即使是新增哪怕一项功能,您的服务可用性都有可能受到影响。总之,请慎重再慎重。
6 微服务监控
下一个主题是微服务监控。在监控微服务时,请确保跟踪整个微服务生态系统的运转情况。具体来讲,我们需要监控的不只是微服务本身,也包括微服务运行所处的应用程序平台、通信环境以及硬件系统。我们可以将监控指标具体划分为基础设施指标与微服务指标,借此获取不同的状态信息,并以此为基础了解能够在哪些层面改进我们的服务水平。
先以包含 JVM 技术栈的微服务为例,这些微服务构建起稳定的应用程序并运行在 AWS 当中。对于此类微服务,我们监控的是 AWS 指标,例如 CPU 利用率、网络流量、内存以及磁盘使用率等。此外,我们还需要监控通信指标,例如负载均衡器状态如何、目前存在多少个健康实例、ELB 与实例之间是否存在连接错误、内部负载的响应速率如何、外部服务负载的响应速率如何、ELB 中的请求率如何、这些请求顺利进入了实例之内还是因无法连接而向客户端返回了错误提示等等。
下面再聊聊微服务指标。这里我们以后端服务,也就是 API 端点为例。我们需要监控每一个商战,查看请求速率以及响应速率,从而了解其是否处于正常运作状态。当然,我们也为依赖项制定了监控指标。对我们来说,这种对依赖项交互方式的了解非常重要。我们会跟进存在依赖项交互错误的账户,同时也会关注故障转移、断路器以及具体断开位置等情况。
再有,我们也建立起指向特定语言的指标。对于 JVM,我们需要监控线程数量,以了解我们的应用程序是否拥有良好的线程使用率,或者在完成某一线程中是否遇到了问题。我们还需要监控内存堆,借此了解我们的 JVM 设置是否正确,或者能够切实有效地整理出与后续改进相关的信息记录。
第二个例子是前端微服务。示例前端微服务以 Node JS 为环境,运行在 Kubernetes 当中。我们会监控 Kubernetes 指标,包括端口数量、CPU 利用率、内存以网络容量等等。整个体系与 AWS 类似,但同样属于基础设施范畴。然后,我们需要关注 Node JS 指标——由于我们在服务器端进行渲染,因此必须确保目前的 Node 环境设置能够以正确方式使用堆与事件循环。毕竟在 Node 当中,我们就只有一个线程,绝对不容有失。
与后端服务一样,我们的前端微服务也拥有端点。二者的差别在于,前端微服务端点返回的是 HTML。我们会监控各个端点的速率以及具体响应时间。所有信息都能通过仪表板查看,非常方便快捷。另外需要强调一点,我们只将仪表板作为指标显示窗口,而不会利用它检测服务中断。
那么服务中断该如何处理?我们选择的是警报机制——即收到通知、停止目前正在执行的操作、采取缓解措施,最后确定问题所在。这里的示例警报显示,有五分之一的实例处于不健康运行状态。引发问题的潜在原因很多,有可能是内存容量不足、JVM 配置错误或者结账服务返回的 400 错误超过了预设的 25% 阈值。当然,问题也可能源自与 API 合约相冲突的新近变更,但遗憾的是我们目前还检测不到这一项。总之,可以看到我们在过去五分钟内一笔订单也没有完成,下游服务正在检测连通性问题。在这种情况下,即使拥有看似可靠的服务模式,结账操作仍然无法顺利完成。
结账数据库的磁盘利用率为 80%。一个可能的原因是,流量的快速增长导致数据库存储趋于饱和。总之,所有这些警报只是在对服务中当下存在的异常状况做出描述,而且全部描述都应具备可操作性。毕竟如果警报上报的是我们无能为力的情况,那么报不报其实就没什么意义了,因为我们只能选择忽略。另外,在结账这个环节中,我们不可能忍受长达数小时的服务不可用问题。所以,保证警报信息的可操作性至关重要。如果大家看到一条警报,但却不知道如何应对这种状况,那么很可能需要认真考虑这条警报的适用性问题。
一旦有了警报触发器,下一步就是评估警报来源的具体症状。如果当前团队内部无法解决问题,就需要通过协调与其他团队取得联系。另外,我们会按照运行手册的说明先尽快止损,采取缓解性措施,然后在取证阶段追溯造成问题的根本原因,同时确保取证结论有助于解决实际问题。最后,就是善后性质的收尾工作。这就是我们在处理取证工作中的一个简单流程。
当然,由于整个公司内部都遵循统一的取证方式,因此每一个环节都必须非常清晰明确。例如,最近五分钟没有完成一笔订单,客户受到影响,2000 名顾客无法结账,业务受到影响,可能造成 5 万欧元的订单损失,对根本原因做出分析。面对这些复杂的情况,我们通常使用“五个为什么”来简化理解。首先是“为什么没有订单?”答案可能是,“因为发生了服务过载,,而且自动规模伸缩策略没起作用。”接下来要问的就是,“为什么没起作用?”依此类推,直到我们确认并收集到引发事件的所有因素。当然,最重要的是接下来的行动部分,各团队甚至组织整体需要行动起来,确保今后不会再次发生类似的情况。
我们要求确保每次事件之后都进行事后分析。虽然编写文档确实相当枯燥无聊,但如果拿不出明确的影响信息,我们就没办法给出“需要提高测试覆盖率以扩大测试范围”这类有针对性的处置意见。这类信息不仅对企业运营而言非常重要,同时也是新功能设计与部署的必要前提。总之,事后取证与分析绝对不容忽视。
7 总结
总结来讲——可靠性模式、可扩展性以及监控机制,共同构建起我们顺利应对黑色星期五的坚定信心。在具体筹备阶段,我们会进行一轮业务预测,发布相应数量的订单,从而在极高的负载压力下测试客户的实际体验。
由于我们的业务整体运行在微服务生态系统当中,因此客户的使用流程必须会涉及到每一个团队。在黑色星期五期间,顾客们可能会转到某个目录页面,选定商品,将其添加到购物车,而后进行结账。每个环节都不能出错,否则客户体验就不完整甚至根本无法推进。在此基础上,我们需要确定使用流程中涉及的所有服务,然后对其开展负载测试。本周之内,我们就将着手进行容量规划,并相应扩展服务,同时确定性能瓶颈以及黑色星期五期间可能需要解决的其他潜在问题。
此外,对于购物流程中涉及的每一项微服务,我们还要整理出对应的检查清单,用于核查其中的架构与依赖项是否正确、是否确定并强化过可能的故障点、是否为所有微服务总结出可靠性模式,以及是否能够在无需部署的前提下实现配置调整等等。
之前我曾经提到,在高流量背景下进行服务转移可能带来风险。特别是在黑色星期五期间,我们绝对不想在这个节骨眼上尝试服务转移。这不单是因为我们害怕出错,也是因为我们是一家认真负责的企业,不能容许这种不专业的问题出现。但必须承认,世上没有万无一失的办法:之前就有一、两年中,黑色星期五巨大的流量甚至耗尽了 AWS 的云资源。我们不想部署并启动更多新实例,因为我们可能再次遇到无法获取更多 AWS 资源的情况。相反,我们必须确保已经确定的所有负载(例如业务规则、客户微服务配置以及我们希望关闭的一切功能切换机制)都能够在无需部署的前提下实现即时配置。
接下来,我们又回顾了自己的扩展策略。在黑色星期五期间,我们可能需要调整平时日常使用的典型自动伸缩策略。而后是是监控机制——所有警报都能正常起效吗?我们的团队是否做好了 24/7 全天候响应服务速率波动的准备?
在黑色星期五的前一天,我们还布置了一间战况室。与这波活动相关的所有团队都聚集在这间战况室当中——当然,每组只选派一名代表。我们会在这里通过监控相互合作,并在发生任何紧急状况时联手解决问题。
我们的黑色星期五请求模式汇总中可以看到,请求先是快速增长、达到峰值,而后持续下降。在最高峰处,我们每分钟需要处理超过 4200 份订单。
回顾这段经历,我的结论是我们需要主动摆脱舒适区,学会预判可能遭遇到的不同错误——而不是坐等错误出现再着手解决。我们应当实施可靠性模式,同时确保所有服务都能够根据需求进行等比例规模伸缩。此外,依赖关系也值得关注,并利用监控手段识别微服务生态系统中的各类指标,从而保证当前环境正按预期方式运行且服务健康状态良好。
英文原文
本文是 Pamela Canchanya 在 QCon 2019 Sao Paulo 上的演讲整理,你可以在下面这个链接访问演讲视频:
https://www.infoq.com/presentations/scalability-zalando/
今日推荐阅读
Shopee 是如何进行数据库选型的?
点个在看少个 bug :point_down:
原文 http://mp.weixin.qq.com/s?__biz=MzIzNjUxMzk2NQ==&mid=2247492826&idx=1&sn=8d780c11fb761cfb44b6bd33616f35b6