1. 概述
今日闲暇之时,头脑风暴了一个问题 — 随着 QPS、业务复杂度的不断增长,哪些因素会成为瓶颈,又应该如何去优化呢? 结合此前的高并发场景相关的工作经验,从以下五点进行了考虑和总结:
- 数据查询
- 加锁优化
- jvm 参数优化
- 业务复杂度
- 容灾
2. 数据查询
2.1. 数据异构
- 分库分表 多维度的分库分表是当 MySQL 查询成为瓶颈最先需要考虑的问题,在此前的博客中我们进行过讨论: mysql 分表策略及 MERGE 表的使用 主要有以下两种拆表策略:
- 水平拆分 例如,对于订单系统,按照年份甚至日期进行历史订单归档,这样可以极大降低高频访问的实时库中的数据量,降低索引空间大小,从而提高查询效率。
- 垂直拆分 同样对于订单系统,有时高频数据量仍然很大,或者对于商品系统,所有商品数据都不能进行归档,类似这样的情况下,只进行水平拆分就显得不是很有效了,这时,垂直拆分就显得尤为重要了。 例如对于订单表,将用户 id 或订单 id 对数字 N 取模,结果映射到 N 个数据库表中,以后通过用户 id 或订单 id 进行查询的时候,只要对 N 取模就可以找到对应的库表编号从而获取到数据。 然而,这存在一个问题,那就是对于订单系统来说,除了带有订单号、用户id的查询外,通过门店 id 进行查询的请求量也是很大的,如果对 N 个表分别进行查询,效率显然是无法接受的。 此时,新建备份库,使用门店 id 对 M 进行取模,得到 M 张分表来解决这个问题即可,通过 databus 等同步机制即可实现两个维度若干张表之间的数据同步了。 然而,这样的解决方案又会引入另一个新的问题,即同步间隔时间的问题,此时,可以通过先写入缓存,再插入数据库,再进行同步,同步后删除缓存的方式进行同步,对门店维度表进行的查询先到缓存中进行查找,没有命中则查询 mysql。 这样的方式复杂度比较高,具体实现仍然需要结合业务的实际情况来进行。
- 数据聚合 当并发量过大时,通过 RPC 框架从各系统获取数据甚至也会成为系统的一个瓶颈。 此时,通过 MQ 或 databus 等机制将数据接收汇总聚合到一起,业务查询时统一查询聚合后数据,可以很大程度上提高响应能力,同时可以降低所依赖的各系统的 QPS。
- 高频数据与低频数据分离 数据库压力主要来源于高频数据的访问,在基本的读写分离的基础上,将高频数据与低频数据分离,单独优化,可以让优化策略更加聚焦,也可以保证优化的效果。 对于超高频数据,可以考虑加缓存、使用 nosql 等方式进一步提高响应性能。
2.2. 增加缓存
上面提到了增加缓存,在此前我们进行抢购系统设计时,曾经使用到了多级缓存,那是一个缓存的最常用使用场景。 订单抢购系统详细设计方案 在抢购系统中我们使用了:
- 代码级缓存 — ConcurrentHashMap,主要用来对 redis 进行保护
- redis 分布式缓存,用来对 mysql 存储进行保护 我们采用缓存读写,定时刷新、同步的机制来进行工作,提高响应能力,保护后端机器。 但是,抢购系统毕竟是一个数据量低、访问频次高、可丢失请求的一种特殊的业务场景,对于数据量大、访问频次高、不可丢失请求的业务场景来说,上文中所涉及的缓存系统就显得有所不足了。 对于数据量大、访问频次高的数据,究竟哪些数据需要缓存,缓存的刷新、淘汰策略,缓存一致性的维护都是需要结合具体业务详细评估的问题,需要具体看待。 博主近期会更新一篇日志,描述常用的缓存淘汰策略与适用场景,敬请期待。
2.3. MySQL 索引优化
MySQL 慢查询是业务中常见的问题,如何科学而又有效地创建索引是业务系统非常常见的一个瓶颈,在此前的日志中,我们提到过 MySQL 优化的相关话题: Mysql Innodb 性能优化 索引相关的主要优化原则有:
- 索引可以加快数据库的检索速度,但是会降低数据库插入、修改、删除等操作的速度,同时索引也需要内存空间进行存储,因此并非越多越好
- 大多数情况下每次查询只能使用一个索引,所以联合索引通常优于单列索引
- 较频繁的查询条件应用于创建索引
- 唯一性差的字段不适合作为单独索引
- 更新频繁的字段不适合创建索引
2.4. 充分异步
例如对于订单查询接口,依赖于多系统的多个不同的 RPC 接口,同时他们之间并无先后依赖的关系,此时通过线程池并发请求,异步收集结果的方式进行请求,可以很大程度上降低接口响应时间,也是非常常用的优化手段。 但是,这引入了一个新的问题,那就是线程池参数的优化,由于我们的系统大多是 IO 密集型,同时如今服务器 CPU 运算能力已经非常卓越,不会成为瓶颈。 因此,我们可以适当调大 corePoolSize,具体数值可以略大于通常状态下的请求 QPS 与响应时间评估出的所需线程数,queueSize 则可以尽量设小,以免队列堆积造成 QPS 突然上升时的响应不及时,maxPoolSize 则可以根据历史请求量的峰值、预期峰值、压测时的响应能力来综合评估。 需要注意的是,线程池的任何一个参数都不是越大越好,都需要结合业务与机器性能进行综合评估,压测是必不可少的,否则很容易因为参数设置不合理造成响应时间变长甚至是线上机器的崩溃。
更进一步的,可以在线程池并发时区分必要依赖于非必要依赖,收集到必要依赖的结果后即可立即返回,从而进一步提高响应能力,降低响应超时。
同时,对于业务上的非必要依赖或永远不会失败的业务流程,考虑异步化,如通过消息队列异步进行,避免在主任务流程中串行执行造成处理时间的不必要耗费。
2.5. 合并请求批量操作
对于 App 的查询请求,可以由服务端对一个页面的多个请求进行封装,服务端数据聚合层进行并发、批量操作,从而降低超时。 对于履约系统而言,单量上升最先遇到的瓶颈必然是分拣人员线下的工作效率,如何提高人员的分拣效率呢?分区、分货架拆分订单,多订单合并商品,批量进行分拣,进行动线规划等方案将极大地提高分拣效率。
3. 加锁
很多情况下,为了避免并发环境下的读写冲突,很多操作必须串行进行,例如订单状态的变更等。 最常用的方案是在工作的前后加分布式锁,从而让操作在并发环境下串行执行,或是让整个过程处于一个 mysql 事务中,但这样的效率是很低的。
3.1. 去事务化
使用 mysql 事务会对数据行加锁,如果在事务中执行耗时较长的业务流程,那么,长事务的存在是很容易造成其他线程等待事务造成响应超时。 同时,在异常情况下,数据库连接异常断开,执行到一半的事务是不会回滚的,从而造成数据的混乱,这是非常严重的一个问题。 因此去除事务对于提高系统稳定性、降低请求耗时都是很有必要的,那么就需要考虑分布式事务。 博主在近期会总结一篇日志,总结归纳在实际场景中,分布式事务的成熟方案,敬请期待。
3.2. 乐观锁
针对加锁造成的性能下降与等待,乐观锁是一个比较好的解决方案。 例如通过数据中增加版本号控制,更新时限制版本,实现并发环境的后置失败,从而避免串行锁造成的性能问题。 但是,乐观锁也有其弊端,对于无法回滚的流程,后置的失败将会是灾难性的,此时需要考虑业务的失败后补偿流程的设计。
3.3. 分段锁
正如上面所说,有些场景的限制是无法使用乐观锁来解决的,此时,如何缩小锁的粒度就需要仔细考虑了,降低锁的粒度,减少锁的时间,防止其他分布式任务的阻塞等待是非常有必要的。 同时,获取锁失败后将阻塞模式改变为其他策略,如异步重试,优先处理其他任务等方式,可以提高系统响应能力。
3.4. redis 事务
在此前的日志中,我们介绍了 redis 的事务,redis 作为串行执行的高性能缓存,其事务的特性在实际的业务中是非常有使用价值的,可以解决很多问题。 由于 redis 事务存在以下缺陷:
- 不满足原子性
- 后执行的命令无法依赖先执行命令的结果
- 事务中的每条命令都会与 redis 服务器进行网络交互
所以很多依赖于事务特性的任务是不能使用 redis 事务来解决的,这时,上文中介绍的 lua 脚本就提供了强大有效的解决方案,这里不展开讲了,上文有详细的介绍。
4. jvm 参数调优
当并发量达到一定程度,java 本身如何管理内存也将成为系统的瓶颈。 此前我们介绍过 jvm 参数调优的相关技巧与原则,在实际的业务场景中,如何结合具体业务设定最优的参数值也是需要反复调整和尝试的。 jvm 参数设置与分析 同时,垃圾收集器的选择也是一个优化的技术点,是使用 ParNew CMS 实现老年代收集的高效呢还是使用 Parallel Scavenge Parallel Old 实现吞吐量的精确控制呢?这都依赖于具体业务的评估。 如果 CPU 性能足够强大,G1 也许是最好的选择。 HotSpot 提供的垃圾收集器
5. 业务复杂度
随着单量的上升,业务复杂度必然成倍上升,业务复杂度的上升,对业务的可维护性,问题的响应与排查,多人共同维护项目带来的挑战都是巨大的。
5.1. 设计模式的使用,模块化,抽象化
业务中的多条业务线往往存在着很多相似性,将流程打碎到多个可复用模块,抽象整个业务流程对于经验丰富的架构设计者是一个非常有效的业务优化手段。 同时,设计模式作为优秀的计算机科学家们总结出的一套成熟解决方案,很多模式是可以直接应用于业务中的,例如对于数据、流程拼接的服务,责任链模式是一个很好的选择,而对于数据聚合等的服务来说,装饰者模式也是经常被使用的模式。
5.2. 业务拆分、微服务化
微服务是近年来服务端架构演化的一个趋势,由于服务的细化拆分与高度内聚,十分便于团队中多人的分工、理解和维护。 同时对于单个项目的监控、容灾、应急响应都将变得非常便于管理。 但是,微服务化也有明显的不足,过度拆分在资源浪费的同时也增加了架构复杂性与维护成本,因此在何时、以怎样的维度和方式对复杂的业务进行拆分是架构设计和管理人员必须考虑的一个问题。 我的原则始终是:不过度设计,在满足当前需求的基础上小步快跑、高速迭代,在复杂度超过拆分产生的维护成本的时机进行拆分,从而兼顾业务复杂度与维护成本。
5.3. 业务预估
对于履约系统这样严重依赖于线下人员操作的业务,进行业务预估,提前预知单量爆发式增长,提前进行相应的分拣打包工作,甚至是对热卖商品进行限购,或对预计送达时间进行自动调整,这一系列策略正是我们现在正在着手去做的事情。 通过历史数据的分析与机器学习算法的模型预估,很容易对即将到来的业务高峰、用户行为做出合理评估和预测,从而做出相应对策。
5.4. 上线、回滚的规范预案
在如今的企业中,上线规范的制定通常都是很基本的一件事,但每次上线前,对多服务复杂依赖的梳理和上线、回滚步骤的评估很多时候是有所不足的,很多时候没有详细列出上线步骤或是回滚、响应的应急预案,成为了重大的安全隐患,这一点在业务复杂度不断提升、微服务不断拆分的系统中就显得更加重要和明显了。
6. 容灾
上面考虑了在业务量不断上升后,系统面临的挑战与应对方案,但事实上,最重要的是其实是问题的发现与响应处理,容灾其实是日常工作的重中之重,又往往不被那么重视。
6.1. 分级报警与值班
随着业务量的上升,监控报警的增加,很容易让开发人员陷入报警的海洋从而对报警视而不见,这时就体现出分级报警与人员值班的重要性。 对于第一优先级的报警必须第一时间处理,但是第一优先级的报警一定不能过多,否则就会造成人员的麻木。 究竟哪些业务需要报警,哪些报警是什么优先级,这需要系统维护人员结合业务仔细评估和考量。
6.2. 业务走势大盘
有时问题并不会明显爆发出来,只能在业务量突然下降的趋势中看出蛛丝马迹,这就是业务大盘的重要性。 例如单量同比、环比的非正常下降往往是因为系统没有立即暴露的问题导致的,观察业务走势对于很多千奇百怪隐藏问题的预警和发现是非常有必要。 包括CPU、内存、网络资源的使用大盘,业务量、响应时间走势大盘,都是日常需要时时关注的重要数据,很多时候,隐藏的内存泄漏如果不及时发现就会酿成大祸。
6.3. 自动降级与恢复
hystrix 提供了简单便捷的配置方法,只需要简单的配置就可以实现对于依赖的自动降级与恢复。 他通过注解加代理的方式实现对类和方法的增强,当达到一定条件,如一定时间内失败次数超过阈值等,hystrix 会自动熔断,依照配置调用默认行为,同时会异步叛活,从而实现熔断后的自动恢复。
6.4. 自动限流
前面提到,我们可以通过机器学习算法对业务量进行预估,从而可以做到自动限流与策略调整。 同时,对于异常请求,如网络攻击与爬虫的识别与自动限流也是必须考虑的。
6.5. 多中间件依赖
很多情况下,中间件的故障影响是灾难性的,我们的系统通常对于中间件是严重依赖的,这样的条件下,多中间件依赖,自动或手动切换是很有必要的,例如MQ与缓存,都有众多解决方案可供选择,异常情况下是完全可以另起炉灶应急响应的,这都依赖于日常的建设。 而业务代码中,则需要对同类型多个不同解决方案进行封装,从而才能做到故障时的自动切换。
6.6. 多版本兼容、灰度发布与ABTest
上文我们提到上线、回滚的规范化,然而仅仅这样是不够的。 在高并发的环境下,如何尽量减少未知问题造成的影响是必须要考虑的。 保证每次上线先后的兼容是开发人员要考虑的基本问题,也是杜绝上线一刻出现问题的基本保障。 而灰度部署,降低影响,做ABTest是上线安全的一道有力防线。
6.7. 压测与流量回放
上述很多方案都必须建立在实际的业务场景下,需要对业务流量进行评估,此时压测就显得十分重要了,只有充分压测才能对系统做到心里有数,机器可以创建多少线程,单机最大能够应对多大QPS,高并发场景下哪些环节会成为瓶颈,这才是系统优化的依据,否则只能是盲人摸象了。 而流量回访系统是在异常发生时,问题排查的有力手段,同时线下人员的提前培训和演练也是依赖线下人员操作的系统所要考虑的一大因素,这也是流量构造、回溯系统存在的价值。 但是,如何保证流量回放系统与线上系统的数据隔离同时又能够与线上业务逻辑保持同步,以及回放系统数据如何生成都是需要设计者充分考虑的问题。