前言
本文是为了介绍一些非常明显,但又容易被忽视的性能陷阱,也可以叫做程序设计缺陷。
这些陷阱可能会导致系统整体资源占用增加,或者程序响应缓慢,或者影响程序横向扩展或纵向扩展能力,甚至会导致系统崩溃。 了解这些问题有助于编写高质量的代码,并且可以帮助开发者更容易的学习系统设计的高级概念。
1. 返回202的异步接口
曾经一个小伙伴跟我介绍他的设计:用户可以批量生成财报,这是一个非常耗时的过程,他将一切逻辑丢给后台线程去执行,然后接口直接返回202状态码给客户端。
这种偷懒的设计实际上是隐藏了性能陷阱的。因为后台线程依然要在这台机器上执行,依然争抢着其他任务的系统资源,假如用户可以重新选择并再次批量生成财报,这将让事情变得更加复杂。
最好的做法是将资源密集型任务转移到独立的后端服务中去做。接口只负责创建任务,并将任务通过消息队列传递给专门的后端。
使用独立后端处理长耗时任务更易于系统横向扩展,也更方便任务管理等。
2. 数据库职责不清晰
案例一:系统有做异地容灾。有一次,系统在短时间内产生了数百万条短信记录,这些数据将自动化同步到某个数据库节点,但是这一行为在短时间内消耗了大量数据库带宽资源,从而导致系统所有接口响应速度变的超级慢。
案例二:一些程序员喜欢写复杂的存储过程或触发器,在成百上千行的存储过程中封装大量的业务逻辑,美其名曰:“存储过程是编译后的sql,执行效率更高”。这里我们不讨论在程序中使用存储过程的优劣,我们只讨论数据库对于系统性能的影响。
数据库通常属于共享资源,在高并发的微服务系统中,它最容易成为系统性能瓶颈。而且数据库纵向扩展能力有限,而横向扩展需要系统基础架构支持。数据库可能会花费大量资源用于计算而无法处理新的请求,而且从成本上来讲,数据库的价格是相对较高的。
将所有数据交给同一个数据存储服务可能会损害性能,主要原因有两个:
- 不同的服务在同一个数据存储中发出大量数据操作将导致资源争用,增加响应时间,甚至连接失败。
- 任何数据存储服务都不一定最适合所有不同类型的数据。
避免这一问题的思路是:
1. 为数据选择合适的数据库。针对案例一,我们最终选择将日志数据迁移存储到Azure提供的Table Storage服务中去,它具备低廉的存储成本优势,以及方便且快速的查询能力。
2. 始终只在数据库执行最简单数据操作,并只使用数据库优化后的基础函数功能。在Sqlserver中,聚合函数,分页函数,数据类型转换函数,以及使用索引列作为Where,Join条件时,都是经过高度优化的,在Sql语句中使用这些是没有问题的。但如果你使用SQL的扩展功能,例如将结果转换成XML,跨库查询大批量数据等,请务必慎重。
3. 同步I/O调用
异步的目的是为了提高响应能力,同步方法也不一定会影响系统性能。
同步I/O(例如网络请求,数据库查询,文件读写等) 绝对是程序当中最常见的性能杀手,在代码里调用同步I/O方法会阻塞当前工作线程,直到I/O操作完成为止。
线程是一种宝贵的资源。线程越多,调度就越频繁,系统计算能力就会下降。因此,最好的做法是尽量重复利用已有线程。
异步I/O指的是当线程执行到I/O代码时释放当前线程,让它可以去执行其他工作。因为一些I/O操作可以由计算机硬件与操作系统结合去完成,例如DMA技术,它在读写数据时并不需要占用CPU和线程资源,只需要在I/O完成之后,通知相关程序继续执行即可。
对于.NET程序员来讲,只需要在写代码时尽可能的使用类库中以Async结尾的方法,并且尽量不用调用.Result(),.Wait()等会阻塞线程的代码。
必须注意的是,类库有时会将接口或基类定义为异步的,但某些实现类其实是同步的,例如MemoryStream,它其实并不需异步I/O操作,它的异步方法是Task包装后的伪异步,因此了解某些常见类库实现原理也是有必要的。
还在使用ADO.NET的朋友尤其需要注意,ADO.NET框架所提供的API大多数都是同步的,而那些繁琐的EAP异步方法相信大多数人都不会去用,因此尽早的切换到提供底层异步支持的框架才是真正的解决方案。
假如你正在使用的类库没有提供异步的I/O方法,你可以用Task等将其包装成异步方法。但一定要清楚,这种做法可以让代码整体延续异步风格,可能会暂时提高调用线程的响应能力,但这不是真正的异步I/O,这样做实际上会消耗更多的系统资源。Should I expose asynchronous wrappers for synchronous methods?
4. 对象生命周期不当
即使你使用的是内存托管的语言,也必须要考虑赋予对象适当的生命周期。
有次线上遇上了一个非常棘手的问题,消息队列服务频繁崩溃,许多服务连接失败,并且出现大量消息堆积。经过运维火急火燎的排查,最终将问题锁定在了当天刚上线的一个新服务上。
这个新服务是公司的小白鼠,使用了最新的开发框架。经过本地调试发现,该服务会多次创建消息队列连接对象。当它上线以后,流量涌入,导致消息队列连接被重复创建且来不及释放,最终导致服务套接字耗尽,并且影响到了系统内所有的服务。
因此,如果你的系统出现套接字,数据库连接,文件句柄耗尽异常;或者内存使用增加,垃圾回收频繁,网络繁忙等,一定要当心了。
当然,Bug如果总是那么明显就好了,事实上,我们必须时刻留意系统中某些对象的生命周期被定义为了什么。例如:HttpClient, DbContext, FileStream等。另外需要留意:
- 对象共享只适用于线程安全的情况,你可以使用它线程安全的方法,但要谨慎修改它的属性
- 有时候连接没有必要长期保持,我们应尽可能聚合I/O操作,也应及时释放连接。
- 共享分为单一和入池两种模式,设计程序时应认真考虑。
5. 琐碎I/O调用与数据缓存
琐碎 I/O 的症状包括高延迟和低吞吐量。 由于 I/O 资源争用加剧,最终用户可能会反映响应时间延长,或服务超时导致失败。
记得当初实习的时候,师父带着我优化一段执行效率特别低的陈年代码。代码有一千多行,里边隐藏着数十个数据库操作,而且有些数据库操作还写在了循环当中,因此该功能在执行时往往需要三五分钟。
事实上,通过合并查询语句,数据批量写入,减少数据库调用次数,可以大大减少系统执行时间。
但是对于微服务系统来讲,仅仅保证自身服务不会出现琐碎I/O是不够的。
你必须在设计服务接口时,充分考虑到与上下游服务的关系,控制接口返回结果的粒度。
例如某个服务需要查询用户生日和电话,假如你设计成两个接口,别的服务就必须调用两次,你的服务也必须查询两次数据库;如果你在一个接口中返回了用户基本信息,那么别的服务可以借助Redis等将信息缓存起来,这样就可以避免频繁的接口调用与数据库查询。
使用数据缓存可以提高数据查询速度,分布式数据缓存的性能消耗主要体现在网络连接,相比于数据库等磁盘查询快了很多。为了避免琐碎I/O发生,使用数据缓存时可以注意以下几点:
- 客户端也可以使用缓存
- 可以预先缓存热点数据
- 缓存需要设置过期策略
- 缓存只是数据的副本,不应看作真实数据源。数据应直接写入数据源,再删除旧缓存值;读取时假如缓存中没有,再从数据源中查询并更新缓存。
- 对于生命周期较短的数据,也可以使用缓存来存储
6. 超量查询
超量查询是琐碎I/O的反例,即一次性读取了过多的数据影响程序性能。一些常见的例子如:
- 查询数据库大量数据而不分页;
- 使用EF的时候将数据一次性加载到了内存,再接着做处理;
- 提供给外部的接口的查询条件过于宽泛
- 频繁计算数据库表中聚合值等
- 查询粒度过粗,导致大量不需要的字段或数据被返回
这通常是由程序设计错误或者开发者对框架代码不熟悉导致的。
优化方法也很简单:我们在设计需要I/O的接口或方法时,应当充分考虑是否需要分页查询,控制查询结果在合理范围;对于聚合运算,数据库系统可能会针对某些函数进行高度优化,但是我们也需要考虑是否需要将聚合值保存起来,并在添加或更新记录后更新该值。这样,在需要该值时,应用程序就不需要重新计算,等。
7. 重试风暴
对于微服务系统来讲,故障后的复原能力尤为重要。因此当系统间调用失败时,通常会选择一定策略的重试操作。
但假如被调用系统不是短暂的故障,而调用方又同时发出大量请求,此时就会产生重试风暴。
因此我们在设计重试策略时必须注意到以下几点:
- 限制重试次数
- 限制重试条件
- 使用指数退避等策略调整重试间隔时间
- 使用断路器模式。断路器模式可以通过滑动窗口法统计某服务调用失败次数,当失败次数达到阈值时将关闭重试机制。
另外服务端可以利用API网关提供的请求速率限制,隔舱模式等功能避免服务端接收到过量请求。