从内部论坛里面偷出来的,的确是高并发的一个好总结(能偷的机会不多了~)。

1 背景

茅台冰淇淋的热度尚未褪去,酱香咖啡又引爆了一波流量,在我们的印象中,茅台似乎就是爆品的代名词,而关于茅台的话题也很容易冲上热搜。去年,茅台推出了官方的数字营销平台「i茅台」,为消费者提供在线预约、售卖茅台酒的功能,其在互联网上的第一次公开亮相同样吸引了极大的关注,也给我们带来了巨大的流量挑战。

本文将结合「i茅台」商城实现高性能与高可用的具体实践,聊一聊常见的性能优化技术以及高可用系统的设计方法,希望能够和大家的日常工作产生共鸣,帮助大家更好地理解并应用这些技术解决问题。

1.1 业务带来的技术挑战

为了更好地理解「i茅台」面临哪些技术挑战我们有必要先了解「i茅台」要解决哪些业务问题。

简而言之,茅台希望通过「i茅台」加强数字化技术在生产销售过程中的应用,规范茅台酒的营销秩序,解决消费者购酒难、购酒贵问题,提升消费者的购酒体验。因此,「i茅台」商城将被打造为茅台酒线上销售的主要入口,为消费者提供两种购买方式:

  1. 享约申购:通过每天定时开放预约申购的方式,采用公证摇号的方式为茅粉们提供了公平的获取平价茅台的机会,是目前爆款茅台酒投放的最主要的方式,我们称之为申购场景
  2. 畅享云购:采用B2C的在线销售模式,目前主要用于系列酒的售卖,但同时也会用于小飞天(100ml飞天茅台)等爆款商品,我们称之为爆款抢购场景

1.2 申购场景

申购场景在业务流程上主要分为五个阶段(如下图所示):

申购场景
申购场景
  1. 注册/登陆/实名:由于酒类销售有年龄限制同时提货需要验证身份证,因此消费者需要在购酒前提供姓名、手机、身份证进行三要素实名认证,这些环节在操作上无时间限制,但短信、实名等依赖第三方服务
  2. 预约申购:普通申购场次是每天上午9点到10点,在22年3月31日试运营第一天就向消费者开放了,按业务估算,每场预计有数千家门店参与库存投放,数百万用户参与申购
  3. 抽签/公证:申购结束后会通过公证处可信抽签的方式确定中签的用户并进行公示,预计有数万用户中签
  4. 交易:中签用户需要在次日18点前完成下单,如选择在线支付,需在指定期限内完成付款
  5. 提货:公示后7天内到预约门店提货

单纯看业务规则,用户在任意时刻发起申购操作其中签的概率都是一样的,然而在功能第一次开放时,用户在不了解规则的前提下,总是会更倾向于在第一时间进行操作,这也是为何开放预约申购的前10分钟就有80%的用户完成了申购,根据我们设计的流量模型,第一次开放预约申购,前5分钟预计最高会达到百万级QPS,在线设备也可能达到百万级,需要重点保障。

因此,申购场景我们主要会面临以下三个技术挑战:

  1. 高并发:大量用户在短时间内集中访问APP也就意味着短时间内会有大量的设备与服务器新建连接,一般来说,一个设备与服务器建立的连接不止一个,这就要求服务器具备同时处理数百万级别(接近千万)的并发连接数及百万级别新建连接能力(也就是通常所说的C10M问题),同时也要求服务端在高并发的条件下具备百万QPS级别的吞吐量及较快的响应速度
  2. 高可用:申购场景的核心链路较长,包括注册、登录、实名、实人、定位、门店选择、申购等多个功能,其中任何一个功能不可用,用户就没有办法完成申购操作,这必然会引起客诉甚至舆情,显然这个是没法接受的,因此,我们需要确保核心链路上面的这些功能具备高可用性
  3. 较高的业务复杂度:申购场景虽然不涉及库存管理(商城部分),但却需要应对门店投放的各种突发事件,确保可申购门店及其投放的商品和数量与商城APP实际展示保持一致,及时识别不一致风险(不一致有可能造成无法履约);另外,APP也需要实时展示各个门店当前的已申购数量,并基于区域、距离、中签概率(结合投放量和已申购数量计算)等因素向用户动态推荐合适的门店

1.3 爆款抢购场景

爆款抢购场景在业务流程上主要分为三个阶段(如下图所示):

爆款抢购
  1. 注册/登陆/实名:同享约申购
  2. 导购/购物车:为用户提供在线商品浏览功能,支持将商品添加到购物车,该功能在去年5月19日正式运营阶段开放,有数千家门店参与
  3. 交易履约:为用户提供组单/下单/在线支付功能,支持多门店合并付款

爆款抢购场景的核心挑战来自于商品本身的稀缺性,只要投放必然会引发抢购。这种模式已经在包括严选在内的各大电商平台上都已经被验证,因此,我们可以参考严选的流量模型进行预估,以当时「i茅台」的用户量及用户活跃度,预计会有数十万的用户参与抢购,我们主要会面临以下三个技术挑战:

  1. 流量洪峰:从业务形态上看,数十万用户在同一时间抢购同一款商品与电商的秒杀活动极为类似,然而「i茅台」又有其非常明显的业务特性,可以说不是秒杀却又胜似秒杀
  2. 峰值流量保持时间长:为了让更多的用户有机会抢购成功,茅台不仅增加了投放量,也加大了投放的频率(现阶段是每10分钟投放一次),这就意味着峰值流量会保持更长的时间
  3. 峰值流量大:早期为了增加云购场景的曝光,业务要求小飞天与「享约申购」放在同一个时间段进行投放(即上午9点到10点),形成了非常明显的流量叠加效应,后来虽然放在了另外一个时段(晚上9点到10点),但出于公平性,库存投放计划会提前向用户预告,爆款抢购的峰值反而还更大了

较高的业务复杂度:相比于传统的电商交易链路,爆款抢购场景会在一段时间内由数千家门店以较高的频率同步投放库存,同时,系统还需要结合区域、距离等因素实时向用户推荐还有库存的门店,也要允许用户手动切换到有库存的门店,这些特性对于高并发场景下数据查询响应的即时性及数据的一致性都提出了更高的要求,也是交易链路应对流量洪峰的重要前提

数据一致性:保证订单、库存、资产、权益(如限购)等数据的一致性是电商交易系统的核心任务之一,在高并发场景下解决数据一致性问题极容易引起系统性能瓶颈,如何在确保数据一致性的前提下实现高性能是一个巨大的挑战

库存管理:爆款抢购场景需要解决库存管理问题,确保商城的销售库存实时反映门店投放计划,并能及时识别潜在的不一致风险,确保库存数量、状态、变更记录准确反映实际情况,保持库存数据的准确性。库存管理可以认为是电商最复杂的系统之一,尤其在高并发场景下,库存管理很容易变成在线交易系统的噩梦,稍有不慎,就会引起少卖、超卖等问题,也很容易成为交易链路的性能瓶颈

1.4 小结

综上分析可以发现,无论是申购场景还是爆款抢购场景,我们都需要在确保数据一致性的前提下,实现系统的高性能与高可用性,而追求高性能和高可用性的目的都是为了提供更好的用户体验、保障系统的可靠性与稳定性。

2 如何进行性能优化

接下来我们看下如何通过性能优化实现系统的高性能

2.1 性能度量方法及诊断工具

要做性能优化,第一步需要明确性能度量指标及度量方法,并借助诊断工具找到性能瓶颈。

常用的性能度量方法及指标主要包括以下几种:

  • 响应时间(Response Time):响应时间是指从发出请求到收到响应的总时间,包括处理请求的时间以及网络传输的时间,通常以毫秒(ms)为单位。响应时间是用户或客户端感知到的时间,反映了系统对请求的响应速度,较低的响应时间通常表示更好的系统性能。
  • 延迟(Latency):延迟是指在执行某项操作或传输数据时经过的时间,可以分为多个组成部分,包括处理延迟(处理请求所需的时间)、传输延迟(数据在网络中传输所需的时间)和排队延迟(等待处理的请求在队列中等待的时间)。在进行性能优化时,我们通常可以通过减少延迟来缩短响应时间。
  • 吞吐量(Throughput):吞吐量是系统在单位时间内处理的请求或事务数量,通常以每秒处理的请求数(如TPS)来衡量。
  • 并发性(Concurrency):并发性是指系统能够在同一时间段内同时处理的请求或任务数量,它可以帮助确定系统在高负载时的性能。
  • 资源利用率(Resource Utilization):资源利用率度量了系统资源(如CPU、内存、磁盘和网络带宽)的使用情况,过高可能表明存在性能问题。
  • 错误率(Error Rate):错误率度量了系统处理中发生的错误数量或百分比,是性能度量的一个关键指标,更大的系统负载往往会造成更高的错误率,在定义SLO时,当错误率超过一定阈值我们往往也会定义为一种宕机的表现。
  • 系统负载(Load):系统负载表示系统正在处理的工作量,可以用来监测系统的负荷,以确定是否需要进行性能优化
  • 延迟分布(Latency Distribution):延迟分布描述了不同请求或操作的延迟情况,它可以帮助我们确定系统性能是否稳定,或者是否存在异常延迟。
  • 性能趋势(Performance Trends):性能趋势分析涉及记录性能指标随时间的变化,使用这些数据来预测性能问题或找到待优化点。

针对性能指标的度量通常需要通过性能监控工具和日志分析工具来完成,「i茅台」采用了与严选相同的选型:

  • 全链路应用性能监控系统(APM):基于Pinpoint构建从网关到应用节点的应用监控体系,支持大流量秒级监控、分布式链路追踪、异常分析等能力
  • 性能监控工具:用于实时监控系统性能,如各类采集器(服务器、数据库等)、Prometheus、Grafana等
  • 日志平台:使用严选自研的日志平台,提供一站式海量日志采集、加工、分流、分析、检索、告警等能力,为应用日志分析、业务大盘等提供数据源和分析能力支撑,更多介绍可以参见网易严选如何建设日志平台
  • 业务实时监控系统:基于Grafana提供海量数据秒级响应的实时监控能力,用户可以通过平台快速完成数据源接入、数据模型构建、监控大盘定制和报警配置

2.2 性能优化策略

识别到性能瓶颈之后,我们需要确定性能优化方案,这里介绍几种常用的性能优化策略

2.2.1 代码优化

绝大部分时候,代码优化是提高应用程序性能的关键动作,常用的策略包括:

  • 代码重构:重写或重新组织代码以提升可读性和执行效率,比如使用更高效的数据结构和算法、减少循环中的计算或减少循环迭代次数、避免在循环内部执行昂贵的操作、引入并发编程、优化事务等等,需要注意的是,重构不一定能使程序的执行效率变得更高,以性能优化为目的的代码重构往往也需要与代码的可维护性之间进行权衡
  • 减少数据库访问:可以通过合并查询、使用缓存、增加前置条件判断或批量操作等方式来减少数据库访问
  • 减少扇出比:控制扇出比的目的是限制系统向其他服务或组件发出的请求数量,从而降低负载,减少扇出比的本质是减少服务间的依赖关系及依赖度,避免大规模的并发请求导致性能下降,也可以降低故障传播的风险。扇出比可用于度量系统向其他服务或组件发送的请求数量,一般我们还可以进一步细化为单次请求中对指定服务、缓存的扇出比,比如下单请求如果会查询两次商品中心的接口,那下单请求对商品中心的扇出比就是2,很显然,这个数值越大,请求被放大的倍数也就越高
  • I/O优化:可以通过将多次I/O操作进行合并或者使用异步I/O来减少磁盘和网络I/O操作,常见的如异步打印日志、将多次服务调用合并成一次调用等等
  • 资源池化:使用资源池来管理数据库连接、异步线程等资源,以减少资源创建和销毁带来的开销
  • 预热:预热是一种通过在应用程序或系统开始处理实际工作负载之前执行一系列操作来提高性能的方法,这些操作旨在将系统的各种组件(如CPU、内存、缓存等)置于一个稳定且高效的状态,以便在处理真实工作负载时获得更好的性能,常见的预热操作包括缓存预热、连接池预热、资源加载预热、数据加载预热等

这里以爆款抢购场景下单接口性能优化为例介绍下上述策略的具体应用:

  • 定制独立下单链路:爆款抢购场景是一种特殊的云购下单场景,有更多的限制条件(如不能加购),因此,我们可以通过裁剪掉一些不必要的流程或者牺牲部分代码可读性以换取更高的性能
  • 下单请求幂等性控制:在爆款抢购场景,由于并发量显著增加,响应时间也会有所增加,甚至会出现响应超时的情况,很容易引发用户连续点击,通过引入幂等性控制,不仅可以降低资源占用时间,也可以减少不必要的库存锁定、扩大销售机会
  • 控制事务的粒度:通过编程式事务(TransactionTemplate)替代声明式事务(@Transactional),可以更灵活地控制事务的范围,在事务中只保留必要的操作,避免大事务,这将显著减少事务的锁定时间和资源占用,带来性能提升
  • 优化分布式事务:为了保证下单阶段订单、库存、资产、权益(如限购)等数据的一致性,我们引入了TCC(Try-Confirm/Cancel)模式的分布式事务,但分布式事务很容易对性能产生负面影响,需要进行调优(如下图所示):
分布式事务
  • 幂等性控制:主事务在调用TCC方法时可能因网络拥堵等原因超时,通常我们会通过引入超时与重试策略来提升成功率,这就要求分支事务需要保证TCC方法的幂等性,避免重复更新。另外需要特别重视优化TCC方法的执行性能,确保在预设的压力下,有足够大比例的请求RT低于超时时间
  • 允许空回滚:在实际的生产环境中,可能因网络拥堵等原因造成Cancel操作先于Try操作到达,因此,允许分支事务空回滚可以避免重试,从而带来更好的性能表现,即允许Cancel操作在找不到待回滚的业务主键的情况下也返回成功并将该业务主键记录下来,同时也要确保空回滚不会产生其他错误效果
  • 防悬挂:如果Cancel操作先于Try操作到达且Cancel操作返回成功的情况下(即允许空回滚),在执行Try操作时,有必要检查空回滚记录中是否存在该业务主键,存在则直接拒绝执行,否则(没有拒绝执行的话)会造成该分支事务悬挂
  • 减少无效回滚:Try操作可能遇到限流、校验不通过等情况造成直接返回,没有执行实际的业务操作,在这类场景,可以在回滚阶段不调用分支事务的Cancel操作,从而带来更好的性能表现

限购优化:为了增加公平性,爆款商品一般会进行限购,即限制同一个用户在某个特定周期内或者某个特定活动中购买同一个商品的数量上限,通常我们需要借助加锁等并发控制手段来确保限购数据的一致性,不同的实现方式对于最终的性能表现有较大的影响,可以进行如下优化:

  • 分布式锁:通过在下单参数校验阶段增加分布式锁,确保同一个用户无法连续下单购买同一个商品,结合上文提到的下单请求的幂等性控制,可以杜绝同一个账号通过多开或者连续点击等方式增大抢购成功率;同时,采用在参数校验阶段加分布式锁相比于限购检查阶段加锁(无论是数据库锁还是分布式锁)消耗的资源更少,性能表现更优
  • 缓存:采用分布式缓存(Redis)记录限购权益消耗情况,限购检查通过缓存替代数据库查询
  • 前置校验:结合业务流程,前置拦截不满足限购要求的请求,比如在商详、组单等阶段进行限购检查,相比于下单阶段检查消耗的资源更少,性能表现更优
  • 订单批量操作:「i茅台」采用的是典型的主子订单结构,主订单又叫支付订单,由1~N个子订单构成,子订单一般采用店铺(门店)粒度,这种方式可以比较容易地实现合并支付以及店铺(门店)粒度的实时分账。在下单阶段,假设用户同时支付N个店铺(门店)的商品,仅订单落库这个环节就需要1+N次DB操作(不含订单商品和订单地址落库),通过优化业务流程,只需要1次DB操作就可以达到相同的效果,从而带来性能提升
订单批量操作

由于爆款抢购都是单商品立即购买,因此,合并插入各个子订单的订单商品及订单地址不会提升性能,反而会增加不必要的代码复杂度

  • 连接池及预热:通过连接池来管理数据库及Redis连接,根据应用并发度及DB负载情况分析连接池大小并设置合理的初始化数量,对性能改善有较大帮助,在此基础上,预热也可以让系统可以更快地进入到最佳状态,比如提前将商品信息加载到本地缓存和分布式缓存、利用就绪探针(如SpringBoot Actuator提供的readiness)提前预热每个服务实例(尤其是关键链路上的实例)以及系统发布后或者关键事件到来之前启动小流量预热等等

通过采用上述优化策略,我们在不增加服务器的前提下,爆款抢购场景的整体吞吐量超过普通云购场景的两倍以上,相比与早期交付版本更是提升了三倍以上。

2.2.2 数据库优化

数据库对系统性能也有着非常重要的影响,常见的数据库优化策略包括:

  • 数据模型设计优化:数据模型设计是技术实现方案的重要组成部分,它不仅直接影响数据库的性能,也会影响数据库的可扩展性和可维护性,通常需要考虑以下两个方面:
  • 选择合适的设计范式
  • 规范化(Normalization)可以减少数据冗余,但可能需要更多的联表操作
  • 反规范化(Denormalization)可以通过增加冗余列、派生列、合并表等策略最大程度避免联表操作或函数计算,提高查询性能,但会增加数据冗余,比较典型的反规范化设计是订单表,通过额外冗余门店、经销商等常用但变更频率很低的信息,可以提升订单查询的效率
  • 适当的数据类型和长度:根据业务需求选择适当的类型和长度来存储数据可以减少存储空间,也可以提升查询性能,如谨慎使用大数据类型(TEXT,CLOB,BLOB等),使用整数而不是字符串存储布尔值,避免存储null值等
  • 索引优化: 数据库索引可以显著提升查询性能,需要合理创建并维护索引,包括考虑哪些列以怎样的次序组合索引、选择适当的索引类型以及定期重建或重新组织索引等;同时,我们也需要了解索引匹配的基本原则,变更前对SQL与索引进行审查,避免最终使用的索引不符合预期(可以借助慢查询日志、explain等工具进行分析调优),需要注意的是,索引也并不是越多越好,它会占用额外的空间,会影响更新操作的性能
  • 查询优化:编写高效的SQL查询语句,包括尽可能避免使用SELECT *、避免全表扫描、谨慎使用联表查询和子查询(我们在业务代码中禁止使用)、限制查询返回的数据量等
  • 缓存:选择合适的缓存组件和缓存策略来存储频繁访问的数据(参见「无处不在的缓存」章节)以减少对数据库的访问,从而降低数据库负载,提升响应速度
  • 读写分离:在一些数据库负载比较高的业务中,可以将读取操作与写入操作进行分离,分别路由到不同的数据库服务器或数据库副本,从而降低主库(写库)的负载,提升响应速度
  • 分库分表:对于数据规模非常大的数据库,可以借助分库分表技术减少单库的负载,从而提升数据库的性能、可伸缩性和容错性

这里以爆款抢购场景库存服务的性能优化为例介绍下这些策略的具体应用:

  • 数据模型设计:结合商城的业务场景,存在数千家店铺(门店)投放同一个商品的情况(即多门店共用同一个商品),也存在商品独家销售的情况(即商品只在同一家店铺销售),每家店铺(门店)的销售库存需要单独管理,基于此,我们为库存服务设计了两张核心表,即库存表和库存流水表(如图所示)
  • 反规范化:库存表新建唯一索引(ShopId, SkuId),库存流水表则冗余字段ShopId和SkuId,新建唯一索引(ShopId, SkuId, OrderId, Type)
库存相关的查询
  • 选择的数据类型:以库存流水表中的Type字段为例,用来表示库存扣减、回滚、投放、回收等状态,显然用tinyint就足够进行存储了,相比于smallint、int类型可以节省存储空间
  • 数据库选型:库存流水表预计每年新产生的数据在千万级且会持续增长,而库存服务又是交易链路的核心依赖,读写操作频繁且有明显业务峰值,如果用单个MySQL实例去支持,存储和性能瓶颈较为明显,因此我们采用了分库分表的技术(网易自研的DDB
  • 负载均衡:均衡字段(拆分键)和均衡策略的选择对于性能有非常明显的影响,需要格外重视,在库存服务这个例子中,库存表和库存流水表我们都使用ShopId作为均衡字段且使用相同的均衡策略,主要有以下几点考虑:
  • 库存相关的查询、变更都会指定ShopId和SkuId,在有数千家门店投放同一个爆款商品的情况下,采用ShopId作为均衡字段可以使数据分布和流量分布更为均衡,库存表采用与库存流水表相同的均衡字段和均衡策略,可以避免XA事务,减少锁竞争
  • 分布式ID:基于美团的分布式Id算法Leaf的统一ID生成服务在高并发场景具有低延迟、高吞吐、高可用、支持水平扩展的特点,同时也可以满足业务上自定义的需求,我们将它作为分布式ID生成的解决方案
  • 定期归档:数据规模的持续增长会对性能带来负面影响,可以考虑将早期的库存流水信息迁移到归档表,可以提升数据库性能

2.2.3 无处不在的缓存

缓存技术是一种被广泛应用于计算机系统和应用程序中的性能优化方法,它通过将数据或计算结果暂时存储在高速存储介质中,使系统可以快速响应请求、返回数据,而无需每次都从慢速存储介质(如磁盘或远程服务器等)中获取数据。

利用好缓存技术可以降低资源负载,减少对数据库、网络或后端应用等外部资源的依赖,显著提升系统的性能与可用性。可以说,在我们现有的系统架构中,缓存几乎无处不在,以申购场景为例:

  • 客户端(APP):客户端通过缓存预置在APP的数据、缓存数据请求响应等方式减少对服务端的频繁访问,提供更为流畅的用户体验,尤其有助于改善首次访问或不稳定网络环境下的访问体验
  • 静态资源访问加速:静态资源访问加速的核心在于就近访问,我们可以通过静态化技术将动态生成的内容或网页转换成静态文件并存储到CDN(Content Delivery Network,内容分发网络)或LB(Load Balancer,负载均衡),在用户请求时离用户更近的节点可以直接提供这些静态文件,而不必再次请求服务端进行动态生成,因此,可以加快页面加载速度、提升网站或应用程序的性能、减少服务器负载并降低对服务器资源的需求。通常静态化技术可以应用于不经常变更的内容、访问量较大的页面、需要SEO优化的页面、静态资源等
CDN
  • 服务端:借助缓存可以减少当前应用对数据库或者其他后端服务的访问,通常我们可以直接使用分布式缓存(如Redis),但在高并发场景(如申购场景),为了增加系统整体的吞吐量,也可以考虑将热点数据设计成二级缓存,即同时使用本地缓存(Local Cache)和远端缓存(Remote Cache,或者叫分布式缓存)
服务端

虽然缓存技术可以显著提升性能,但同时也极大提升了系统设计的复杂度,需要考虑缓存的一致性、失效策略和缓存维护等关键问题,如果没有处理好,很容易发生缓存数据不一致、缓存大面积穿透甚至引发雪崩。

以申购场景使用的二级缓存为例,我们需要处理好以下问题:

  • 技术选型:本地缓存采用Caffeine,远端缓存采用Redis Cluster
  • Caffeine:相比于EhCache、Guava等主流的缓存框架,拥有更加强大的性能表现,使用方式上与Guava类似,非常方便
  • Redis Cluster:Redis Cluster是Redis提供的分布式缓存解决方案,也是目前主流的解决方案,相比于Proxy模式有更好的性能表现,但我们仍然需要重点关注硬件以及混部等因素对于性能及稳定性的影响
  • 避免缓存穿透:传统的关系型数据库对于并发的承受能力非常脆弱,如果我们设计的缓存命中率不高出现大面积缓存穿透,很有可能将数据库拖垮,如何避免缓存穿透是我们设计缓存时需要重点考虑的问题:
  • 热点缓存采用预加载、定时刷新及事件触发刷新(如数据变更)的策略,保证100%命中率
  • 当查询的结果为空时,仍然将空结果(自定义NullObject)存储到缓存中(可以设置一个较短的过期时间),这样可以防止恶意请求的连续查询
  • 消息队列与异步化
    异步化的核心思想是将耗时的操作从主流程中分离出来,以允许应用程序在等待这些操作完成的同时继续执行其他任务而不会被阻塞,从而改善系统的响应性和资源利用率。

异步化通常可以通过多线程、多进程、事件驱动或异步编程模型等方式来实现,有很多中间件和框架可供我们选择,比如通过DisruptorBlockingQueue等技术将任务分解为多个线程或进程以充分利用多核处理器的性能,通过消息队列(MQ)实现异步消息通信和服务解耦,达到对流量进行削峰填谷的效果,提升系统的可扩展性和性能。

这里重点提一下消息队列在「i茅台」的应用,无论在申购场景还是在爆款抢购场景(如下图所示),我们都需要借助消息队列实现对洪峰流量的削峰填谷,避免服务器过载或系统宕机,同时也可以实现数据的最终一致性,确保消息处理的结果与业务逻辑的一致性。

场景

不难发现,这其中的关键挑战在于消息队列本身的高性能以及在设计上如何确保数据的最终一致性。

先来看消息队列的选型,在严选,因为历史原因同时存在Kafka、Rabbitmq和RocketMQ,但综合考虑性能和稳定性表现(高吞吐量、低延迟、高可用)、功能特性丰富度、工具支持丰富度、社区活跃度等维度,RocketMQ最终成为业务系统消息队列的主流选型,「i茅台」则延续了这一选型,采用主从部署模式(4.8版本之前主从模式相对Dledger模式在性能上更有优势)。

接下来我们看一下如何确保数据的最终一致性。

先介绍四种我们平时开发过程中比较容易出错的实现方法(如下图所示):

四个方案
  • 方案一:先执行数据库操作再发送消息到MQ,可能会出现数据库操作成功,消息发送失败的情况
  • 方案二:先发送消息到MQ再执行数据库操作,可能出现消息发送成功,数据库操作失败的情况
  • 方案三:在方案一的基础上,开启数据库事务,这个方案在消息发送失败抛出异常的情况下可以正常回滚,但有可能会出现消息发布至MQ成功但请求失败的情况(如网络拥堵等原因响应超时),这种情况也会引发事务整体回滚
  • 方案四:在方案二的基础上,开启数据库事务,这个方案如果数据库操作失败需要回滚,但MQ已经发生成功,没有办法回滚

可见,上述四个方案都有可能出现数据库操作状态和消息发送状态不一致的情况,其中方案一可能出现数据库操作成功、消息发送失败,这类异常可以通过引入消息补偿机制来确保消息最终成功投递;方案二、三、四可能出现消息发送成功、数据库操作失败,这类异常可以通过在消息消费端增加消息状态确认或者类似的校验机制,确保被投递出去的消息不会对业务产生负面影响。

最后介绍两种比较常用的正确实现方法:

  • 方案五:基于RocketMQ的事务消息来实现
基于RocketMQ的事务消息来实现
  • 步骤一:消息生产者向RocketMQ发送半事务消息(1. Prepare),RocketMQ确认消息接收状态
  • 步骤二:RocketMQ消息接收成功,消息生产者执行本地事务的业务逻辑
  • 步骤三:消息生产者根据本地事务的执行结果向RocketMQ提交二次确认(2. Commit/Rollback),RocketMQ将步骤一中收到的半事务消息标记为可投递(消费者就可以消费到这个消息)
    如果因断网或者应用重启等原因,二次确认(2. Commit/Rollback)没有成功提交,RocketMQ会定时触发事务消息回查,确认是否需要投递(兜底策略),无需投递的消息会在过期后删除
  • 步骤四:RocketMQ将消息投递给消息消费者(3. 投递消息),消息消费者首先需要进行幂等性检查,通过检查后执行本地事务的业务逻辑,最后返回执行结果(4. Ack)
  • 方案六:基于消息补偿机制来实现
基于消息补偿机制来实现
  • 步骤一:在同一个本地事务中执行业务逻辑中的数据库操作和新增消息补偿记录(1. Prepare: 新增记录)
  • 步骤二:本地事务提交后,启动异步线程,向RocketMQ发送消息(2. 发送消息),消息发送成功后删除消息补偿记录(3. Confirm: 删除记录)
    如果因断网或者应用重启等原因,发送消息失败或者未成功删除消息补偿记录,消息生产者会定时触发消息补偿,确保发送到RocketMQ的消息至少发送一次(at least once策略,MQ有可能存在多条相同的消息)
  • 步骤三:RocketMQ将消息投递给消息消费者(4. 投递消息),消息消费者首先需要进行幂等性检查(避免重复执行同一个消息),通过检查后执行本地事务的业务逻辑,最后返回执行结果(5. Ack)

结合业务实际情况,由于RocketMQ的事务消息相比于普通消息性能上还是有不小的损失,无法完全满足我们的性能要求(服务器规模不变的前提下),因此我们最终选择了实现上更加复杂的方案六。

2.2.4 硬件升级及资源优化

硬件的性能和资源利用率同样也是我们性能优化过程中需要关注的地方,如果我们把不同的应用比喻成军队中不同的兵种,那么硬件就给不同兵种配置的装备,只有合理搭配,这些装备才能最大程度提升各兵种的战斗力,而合理搭配装备的底层逻辑,是对资源的最大化利用、避免浪费。

事实上,资源筹备工作往往是先于产品开发工作开展的,因此,我们的性能优化工作在绝大部分时候是在资源不变的前提下进行的。为了利用好这些资源,通常我们需要解决以下三个问题:

  1. 资源筹备阶段:如何准确地预估需要的资源?

  2. 应用架构:从已知的业务信息中分离出不变的部分和变化的部分,输出全局应用架构和系统应用架构(包含应用及其依赖项),一般而言,中后台应用不变的部分更多容易预估,前台应用不确定性高也更难预估,但随着需求逐渐明朗(逐步进入产品研发阶段、产品运营阶段后),加上有更多的测试数据,预估也会越来越准确,因此,应用架构应保持持续演进以更好地厘清依赖关系及资源需求

  3. 部署架构及资源清单:将应用架构映射成部署架构是资源预估的重要步骤,这个阶段一般还没有办法输出完整的流量模型,但架构师可以借助业务预判(如什么样的业务形态、多少用户参与等)拆解出核心域的前端入口流量(如申购流量、交易流量),各个域再逐层拆解到各个应用,最终映射出资源清单(也就是第一版资源清单),不难想象,要提升这个阶段资源预估准确性非常困难,需要业务方、开发团队、运维团队紧密协同,业务输入越充分预估会越准确,应用架构设计越完整预估会越准确

产品研发阶段:如何结合应用特性合理地搭配和使用资源?

通常SRE会定义出不同的资源规格供各类应用选择:

  • CPU性能:CPU性能对于计算密集型任务非常重要,应用如果需要大量计算,需要配置高性能的CPU
  • 内存容量:内存容量对于数据密集型应用和需要缓存大量数据的应用至关重要,足够的内存可以减少对磁盘或网络的访问,提高性能
  • 存储:存储配置取决于数据量和性能需求,使用高性能固态硬盘(SSD)可以提高数据读写速度
  • 网络带宽: 如果应用需要大量的数据传输,网络带宽是一个瓶颈,一般负载均衡设备、应用网关、CDN需要重点考虑
  • 容量规划与流量模型设计:这个阶段需求已经非常明确,可以基于场景及最新的应用架构进行更为细致的流量拆解,评估出每个系统(尤其是核心系统)的容量要求及资源清单,并通过压测进行验证,确保系统在各种负载情况下都能够正常运行
  • 产品运营阶段:如何最大化地利用好现有的资源?
  • 资源超售或混部:一般可以通过云厂商提供的资源超售能力或者研发团队主动发起应用混部来提高资源利用率,然而这却是把双刃剑,更高的资源利用率也就意味着更低的资源容错率,一旦出现预期之外的流量或者资源占用,很容易成为压垮系统的最后一根稻草
  • 硬件升级及资源优化:通过诊断工具识别在压测或产品运营过程中出现的资源异常,如CPU使用率或Load不均衡、I/O延迟大、内存使用率高等特征

2.3 小结

性能优化是产品设计、研发和运营过程中的一个极其重要的环节,通过性能优化可以确保我们的系统满足性能设计目标。通常我们需要先借助性能诊断工具识别性能瓶颈,然后结合实际情况综合选择一种或者多种性能优化策略。

但需要特别注意的是,过早的优化可能会引入不必要的代码复杂性而性能却未必改善,因此,我们建议在系统开发的早期阶段应更侧重于保持代码的可维护性和可读性,随着系统的持续演进,再基于性能诊断结果对代码进行针对性地优化以满足性能需求。

3 实现高可用的主要策略

高性能并不意味着高可用,有些提升性能的手段会增加系统负载,反而还会降低可用性,而为了实现系统的高可用,我们通常需要引入一些复杂性或冗余,可能还会对性能产生一定负面影响。

本章节会重点探讨一下在系统架构和设计阶段如何考虑可用性目标。

3.1 面向失败的设计

任何服务和组件都不是100%可靠的,因此核心系统的设计建议面向失败进行设计,即确保在部分组件或服务故障时仍然能够继续提供服务,比如前文提到的申购场景和爆款抢购场景都使用了这一设计理念。

这里我们总结下几种常用的策略:

  • 最小化依赖:要减少对外部服务和组件的依赖,特别是减少强依赖,从而降低故障传播的风险
  • 冗余和备份:通过引入冗余组件或备份系统,在主要组件故障时可以无缝切换到备用组件,从而确保服务的连续性,需要特别重视的是,关键组件要避免单点
  • 自动故障检测和恢复:使系统能够主动检测故障或异常情况,并采取自动化措施以恢复正常运行,通过这种方式可以减少对人工干预的依赖,从而更快地响应问题,降低服务中断的风险
  • 超时与重试:在网络通信中,设置适当的超时时间,以防止请求挂起;使用重试机制确保请求的可靠性;结合合理的退避策略,避免过度重试导致服务器负载过高
  • 限流与降级:实施限流策略,控制请求流量以防止系统过载,在高负载或故障时降级部分功能,保持核心功能的可用性
  • 监控与报警:建立监控系统,实时追踪系统性能和健康状态,设置报警规则以在问题发生时及时通知运维团队采取行动
  • 应急预案:制定应急预案,在系统故障或紧急情况下快速采取行动以达到最大限度保护系统的目的,包括降低系统负载、故障止血与恢复、数据备份等等

3.2 微服务架构

微服务架构是一种典型的容错架构,由于「i茅台」商城在设计阶段已经明确了业务模式和流量挑战,因此我们没有像严选早期那样采用单体架构快速上线,而是直接采用微服务架构进行设计,基础设施则复用了严选的Service Mesh架构(参见网易严选ServiceMesh实践),相比于单体架构在以下几个方面具有明显优势:

  • 故障隔离:微服务架构将一个应用程序被拆分成一组小型、独立的服务,每个服务都专注于执行特定的业务功能,当一个服务发生故障时,不会影响其他服务的正常运行,从而减小了故障的传播范围,提高了整体系统的可用性
  • 水平扩展:微服务架构使得每个服务可以独立地进行水平扩展,可以根据需求增加或减少服务实例的数量,以满足不同的负载要求,从而提高了系统的弹性和可用性
  • 快速故障恢复:微服务架构具备自动故障检测和切换机制,系统能够在检测到故障时自动将流量切换到其他服务实例,从而实现快速故障恢复,减少了服务中断的时间
  • 分布式部署:微服务架构支持分布式部署,服务可以部署在不同的服务器上(甚至可以跨数据中心进行部署),这提供了更高级别的容错性,避免单点故障
  • 灵活的更新和维护:由于每个服务都是独立的,可以单独更新和维护,而不会影响整个系统的可用性,这降低了维护和更新过程中的风险,减少了系统的停机时间

有了基础架构提供的服务治理能力加持,开发需要在系统设计和开发阶段重点关注以下几点方面:

  • 服务分级:服务分级是服务关联的一个标签,可以区分出每个服务对于业务影响的重要程度,我们认为每个服务都应该有对应的分级标签,它可以让我们更清晰地了解服务的可用性目标以及服务之间的依赖关系是否合理

通常更高等级的服务应该匹配更高等级的服务保障,也应该具备更高的可用性,因此,要避免高等级的服务强依赖低等级的服务,否则容易造成高等级的服务无法达到既定的可用性目标

  • 服务依赖:由于微服务架构会将服务拆分成更小的单元,这就不可避免地增加了服务之间的依赖关系,通常我们可以根据对故障的容忍度将依赖关系区分为强依赖和弱依赖,在设计上建议遵循以下原则:
  • 强依赖弱化:强依赖的服务应当尽量减少或减弱,以降低整个系统中某个服务的故障对其他服务的影响
  • 弱依赖异步化:弱依赖的服务可以采用异步通信方式(如消息队列),以降低对依赖服务的直接调用,提高系统的弹性和响应性
  • 超时治理:超时治理是通过优化超时时间与重试策略,使尽可能多的请求能够在预期时间内得到正常响应,提高系统的响应性,是一种重要的服务治理手段

超时时间的设置应充分考虑业务本身的复杂性和预期响应时间,应设置足够长以容忍正常的响应延迟,但也不能过长避免无限期挂起等待

响应超时并不一定意味着目标服务已经不能工作,通过合理的重试机制,即使在目标服务器或网络故障的情况下也能够成功完成请求,但要确保请求的幂等性,避免重试请求对业务产生负面影响

设计合理的退避策略(如指数退避),避免连续重试导致服务器负载过高,另外,重试次数也应该有限,避免无限重试

在放弃请求后,系统可能采用降级策略,提供有限的服务,以确保核心功能的可用性

  • 限流策略:限流是一种通过控制请求流量以防止系统过载的策略,也是一种非常重要的服务治理手段

限流会带来一定的性能损耗,我们借助应用网关与限流中间件实现对流入网关及业务系统的流量进行限制,各个系统需结合服务等级、预估流量及应用当前能力选择是否开启

限流值的设置应充分考虑应用自身的能力,由于系统演进过程中的熵增是一种不可避免的趋势,建议限流值设置时保持一定的余量,以最大限度为系统提供有效保护

限流策略上可以为每个服务或接口设置最大请求速率,也可以进一步基于时间段、用户、IP地址等因素进行细化

  • 降级策略:降级策略是一种应对系统负载过高或故障的策略,通过牺牲非关键功能以保持核心功能的正常运行
  • 主动降级:系统在监测到一定条件或预设的规则触发下,自动执行降级策略,包括自动关闭非关键功能、拒绝某些请求、减少资源分配等
  • 手动降级:运维人员或开发人员手动介入执行降级策略以应对特定的问题或异常情况,通常都是应对已知的问题或紧急情况,基于预案进行操作
  • 熔断策略:当一个服务在一段时间内出现连续的失败,熔断策略会中断对该服务的请求,避免因频繁请求失败而导致的资源浪费

3.3 客户端(APP)容错设计

客户端(APP)作为用户获得产品服务的主要入口,也大量使用了容错设计。

容错设计可以提升客户端整体的鲁棒性和可用性,以更好地应对服务端或网络等各种故障和异常情况,确保客户端在面临这些问题时仍然能够提供有限的服务,保持良好的用户体验。因此,客户端的容错设计往往也和产品策略息息相关,而不同的产品策略最终也会影响技术方案的选择,比如:

  1. 首页无论在出现哪类异常都不应该挂掉,那么,我们在设计阶段就应该充分考虑请求失败等异常情况下如何进行兜底展示
  2. 大流量场景可能遇到限流等异常,可以通过设计等待页面或者排队动画避免流程中断(比如操作了一半进入错误页面)、减轻用户等待的焦虑感
  3. 随着版本的持续迭代,旧版本APP的用户是否还能正常使用产品服务

设计明确的前后端交互协议有助于更好地解决上述问题:

  • 错误处理和容错机制:通过规定统一的错误码和错误信息传递方式,使APP能够捕获和处理各种错误情况,包括网络错误、服务端错误、数据格式错误等,这有助于客户端实现统一的错误处理方法以应对各种异常情况
  • 版本兼容性:通过版本控制,可以确保在服务端升级或修改接口时不会影响现有的APP版本,保持兼容性;也可以通过版本控制,提醒用户或者强制用户升级到最新版本的APP
  • 通信安全:通过定义加密和认证规范,以确保通信的安全性,这有助于防止数据泄露和中间人攻击,提高系统的可靠性和安全性

3.4 全链路压测

类似「i茅台」这样大型的电商系统,具有业务场景复杂、核心链路长的特点,通过传统的测试方法在测试环境进行压测,已经难以确保系统在高负载和极端场景下的性能、可用性和稳定性了,因此,我们需要引入全链路压测。

全链路压测是在生产环境中基于真实的业务场景模拟用户操作和流量,以对整条业务链路进行压力测试,从而识别潜在的性能瓶颈或异常,有助于我们及时发现并解决,确保系统的高性能、高可用和稳定性。

要实现生产环境全链路可压测,除了前文提到的APM、日志平台等监控诊断工具之外,还需要具备以下能力:

  • 全链路流量标记透传能力:通过全链路支持压测流量标记识别和传递,使线上全链路能区分压测流量和真实用户流量,保证在大流量压力测试时不会对真实用户体验以及真实用户数据造成影响,解决了因环境差异造成压测结果不真实,可以充分验证线上服务在大流量下的承载能力
  • 数据存储路由:使数据库、缓存、MQ、日志等存储介质可以识别压测流量,将压测产生的数据与真实用户数据分开保存,实现存储隔离,为线上压测提供数据安全保障
  • 压测平台:提供分布式高并发压测能力,支持上千台压测机同时发出压测流量、模拟千万级用户访问,支持大流量下的压测结果分析

全链路压测要求技术团队进行更高效的协同,因此,除了工具和能力层面的支持,团队的成熟度也是影响这项工作能否顺利开展的关键因素:

  • 明确核心链路:需要共同明确哪些是需要重点保障的场景,并通过业务梳理明确每个场景对应的核心链路
  • 容量规划与流量模型设计:结合产品运营计划,对每个场景的流量进行预估与拆解,明确核心链路上各个系统的性能指标、容量指标、SLA以及各自的强弱依赖(包括第三方服务,如各类云服务),并明确对依赖方的性能要求及降级预案
  • 限流配置及预案制定:结合各个系统的性能指标和容量指标,明确限流策略及限流值,梳理可能出现的异常场景并制定针对性的预案(如降级、熔断等)
  • 压测执行及预案演练:定期组织全链路压测并在压测前、压测中进行预案演练
  • 监控与报警:每个系统需要在各自的关键链路上设置监控和报警,以便在压测期间能实时监测性能及异常,并能够进行快速响应
  • 结果分析:分析并解释压测结果,识别预案及监控报警是否有效,识别是否存在潜在的性能问题和优化机会,最终产出压测报告
  • 改进优化:各个团队根据压测报告讨论并实施优化策略
  • 常态化基线压测及性能巡检:建立常态化机制,定期对系统进行性能测试以建立性能基线,以便了解系统在正常工作负载下的性能指标;定期监测和评估系统的性能,以便及早发现和解决性能问题

3.5 小结

要实现系统的高可用性,我们需要在系统架构和设计阶段充分考虑各种故障和异常场景,包括硬件故障、网络中断、软件错误等,通过减少依赖、引入冗余和备份、实现自动故障检测与恢复、增加限流及应急预案、引入监控报警机制等策略来减轻突发流量或故障对系统产生的影响,确保在部分组件或服务故障时系统仍然能够继续提供服务,同时我们也必要借助全链路压测等手段识别潜在的问题以确保上述策略是持续有效的。

4 总结

「i茅台」商城作为茅台酒线上销售的主要入口,从诞生的第一天开始就需要考虑如何有效地应对大流量高并发的考验,这要求我们在保证数据一致性的前提下,实现系统的高性能和高可用,其中高性能意味着更快的响应时间和更大的吞吐量,可以同时为更多的用户提供流畅的服务,而高可用则意味着系统需要在面临故障或异常情况时仍然保持可用性。

为了实现系统的高性能和高可用,我们需要结合业务诉求、产品运营情况及系统现状进行分析,综合选取一些性能优化技术与高可用系统的设计方法,使系统在性能、可用性、安全性、可维护性等方面保持一种最佳的平衡状态。

本质上,系统的架构和设计就是权衡和取舍的过程,权衡性能与可用性、成本与性能、安全性与便利性、扩展性与复杂性,不同的系统可能需要不同的权衡,本文希望通过这些实际的案例帮助大家更好地理解这些技术和策略以及背后的权衡过程,从而可以更好地应用在我们日常的设计与开发过程中。