是当系统处理大量磁盘 IO 操作的时候,由于 CPU 和内存的速度远高于磁盘,可能导致 CPU 耗费太多时间等待磁盘返回处理的结果。对于这部分 CPU 在 IO 上的开销,我们称为 “iowait”。
iowait 怎么查看呢?
如果你用的是 Linux 系统或者 Mac 系统,当你在执行一项很耗费磁盘 IO 的操作时,比如读写大文件,通过 top 命令便可以看到。如下图所示:
CPU 开销示意图
其中的 2.6 wa 便是 iowait 占用了 2.6% CPU。
那么,这种 CPU 开销对性能会有什么影响呢?特别是像秒杀这样的高并发系统,当秒杀服务运行的时候,会输出大量信息到日志文件,比如程序报错信息、请求参数的调试信息等,而这些写日志文件无疑会给磁盘带来更大的压力,导致更多的 CPU 开销。所以,这一讲我们就主要来探讨下这个问题。
秒杀日志面临的问题
对于并发不高的服务,我们可以把所有需要的日志写入到磁盘上的日志文件里。但是,在高峰期间,秒杀服务单节点需要处理的请求 QPS 可能达到 10 万以上。一个请求从进入秒杀服务到处理失败或者成功,至少会产生两条日志。也就是说,高峰期间,一个秒杀节点每秒产生的日志可能达到 30 万条以上。
这是什么概念?
磁盘有个性能指标:IOPS,即每秒读写次数。一块性能比较好的固态硬盘,IOPS 大概在 3 万左右。也就是说,一个秒杀节点的每秒日志条数是固态硬盘 IOPS 的 10 倍!如果这些日志每次请求时都立即写入磁盘,磁盘根本扛不住,更别说通过网络写入到监控系统中。
所以,秒杀日志会面临的第一个问题是,每秒日志量远高于磁盘 IOPS,直接写磁盘会影响服务性能和稳定性。
另外,服务在输出日志前,需要先分配内存对日志信息进行拼接。日志输出完,还需要释放该日志的内存。这将会导致什么问题呢?
对于那些有内存垃圾回收器的语言,如 Java 和 Golang ,频繁分配和释放内存,可能会导致内存垃圾回收器频繁回收内存,而回收内存的时候又会导致 CPU 占用率大幅升高,进而影响服务性能和稳定性。
那些没有内存垃圾回收器的语言,如 C++ ,又会受什么影响呢?它们通常是从堆内存中分配内存,而大量的分配、释放堆内存可能会导致内存碎片,影响服务性能。
所以,秒杀日志会面临的第二个问题是,大量日志导致服务频繁分配,频繁释放内存,影响服务性能。
最后,秒杀日志还会面临服务异常退出丢失大量日志的问题。
我们知道,由于秒杀服务处理的请求量太大,每秒都会有很多请求的日志未写入磁盘。如果秒杀服务突然出问题挂掉了,那这批日志可能就会丢失。
对于高并发系统,这在所难免,问题是如何把控好写入日志的时间窗口,将丢失的日志条数控制在一个很小的可接受范围内。
这就是秒杀日志面临的第三个问题。通过上面的介绍,想必你也明白了,像秒杀这种大流量业务场景下,日志收集是个大难题,也是个必须要解决的性能问题。
如何优化秒杀日志性能?
前面我们了解到,秒杀日志面临着磁盘 IO 高、内存压力大、大量丢失等风险,归根结底,还是因为日志量太大,常规日志保存手段已经无法发挥作用。怎么办呢?接下来我就对这几个问题一一介绍下。
磁盘 IO 性能优化
首先,我们来看下秒杀日志量超过磁盘 IOPS 的问题。
上一讲我给你介绍了多级缓存,你是否还记得内存性能和磁盘性能的差别呢?没错,内存性能远高于磁盘性能。那我们能否利用内存来降低磁盘压力,提升写日志的性能呢?答案是可以。
Linux 有一种特殊的文件系统:tmpfs,即临时文件系统,它是一种基于内存的文件系统。当使用临时文件系统时,你以为在程序中写文件是写入到磁盘,实际上是写入到了内存中。临时文件系统中的文件虽然在内存中,但不会随着应用程序退出而丢失,因为它是由操作系统管理的。
由于云架构保障了云主机的高可用,只要操作系统正常运行,也没有人删除文件,临时文件系统中的文件就不会丢失。所以,我们可以将秒杀服务写日志的文件放在临时文件系统中。相比直接写磁盘,在临时文件系统中写日志的性能至少能提升 100 倍。
当然,临时文件系统中的日志文件也不能无限制地写,否则临时文件系统的内存迟早被占满。那该怎么办呢?可以这样处理,比如,每当日志文件达到 20MB 的时候,就将日志文件转移到磁盘上,并将临时文件系统中的日志文件清空。 相比频繁的小数据写入,磁盘在顺序写入大文件的时候性能更高,也就降低了写入压力。
内存分配性能优化
不知道你学过 C 语言没?如果学过的话,你应该对 malloc 函数和 free 函数不陌生。malloc 函数主要用于从堆内存中分配内存,而 free 函数则是将使用完的内存归还到堆内存中。堆内存是由系统管理的,当堆内存中有大量碎片时,为了找到合适大小的存储空间,可能需要比对多次才能找到,这无疑让程序性能大打折扣。
而秒杀服务在输出大量日志的时候会存在频繁的内存分配和归还,如果使用常规方式分配内存,会导致高并发下性能下降。所以,我们需要使用高效的内存管理,既能快速分配内存,又能避免频繁触发垃圾回收器回收内存。
怎么做呢?
我们可以参考共享单车运营方的做法。像摩拜、哈罗、青桔等,单车的起步和归还都在人流量大的投放点,而不是运营方仓库。假如在程序中,我们也能像共享单车一样,根据实际业务自己管理内存的分配和归还,就能避免譬如内存碎片和内存垃圾回收,导致性能降低的问题。
具体怎么实现?
对于秒杀系统来说,它的日志里需要附加一些信息,以便后面排查问题或者数据统计,这些附加信息有用户 ID、来源 IP、抢购的商品 ID、时间等。但日志文件是纯文本的,而附加信息中有的是整数,有的是字符串,这就需要统一拼接成字符串才能输出到文本文件中。然而,在像 Java、Golang 这类高级语言中,字符串是一个经过封装的对象,底层是字符数组。直接用字符串拼接的话,会导致程序分配新的字符串对象来保存拼接后的结果。
比如下面的代码就会触发内存分配。
str := "hello " + userName
如何避免字符串内存分配呢?一般我们可以直接使用字符数组,基于字符数组做参数拼接。典型的例子是实现一个带字符数组缓冲区的日志对象,提供类似 AppendInt、AppendString 这样的方法拼接参数。比如下面这部分。
type Logger struct{
data []byte
}
const maxDataSize = 65536
func NewLogger() *Logger {
l := &Logger{
data: make([]byte, 0, maxDataSize)
}
}
// 整数转成字符数组并追加到缓冲区
func (l *Logger)AppendInt(data int){
d := strconv.Itoa(data)
l.data = append(l.data, d...)
l.tryFlush()
}
// 字符串转成字符数组并追加到缓冲区
func (l *Logger)AppendString(data string){
l.data = append(l.data, []byte(data)...)
l.tryFlush()
}
// 关闭 Logger,将缓冲区中数据写入到日志文件中。通常在程序退出前调用该函数。
func (l *Logger)Close(){
l.Flush()
}
func (l *Logger)Flush(){
// 此处省略具体写文件的代码,大家可以自行练习
// 将字符切片指向 l.data 的头部,清空缓冲区
l.data = l.data[0:0]
}
func (l *Logger)tryFlush(){
// 超过 64KB 则写入到磁盘
if len(l.data) >= maxDataSize {
l.Flush()
}
}
在上面的代码实现中,每个 Append 函数中采用追加的方式拼接参数,在缓冲区足够用的情况下,不会为拼接后的数据重新分配内存。
怎么确保缓冲区足够用呢?
答案是最后面的 tryFlush 函数,它能控制缓冲区中的内容不会过大。 当 tryFlush 函数发现数据长度超过设定的最大值时,会将数据写入到日志文件中并清空缓冲区。在这个过程中,Logger 不需要归还、再分配缓冲区。
当然,以上只是个简单的示例,真正生产环境中用的 Logger 要强大很多。感兴趣的可以看看 zap、logrus 等 Logger 的实现。
如何减小丢日志的风险
前面我们了解到,秒杀服务在高并发下发生异常的时候可能导致部分日志丢失。我们还了解到,秒杀服务日志不能实时写入到日志文件。有没有发现,这两件事情是互相矛盾的?实际上,在高并发下,我们无法彻底解决丢日志的风险,只能减小丢日志的概率。为啥呢?
在高并发下,我们需要尽可能将日志先缓存到程序本地内存中,也就是 Logger 的缓冲区中。当日志到一定量后,批量写入日志文件,以便达到良好的写入性能。但是,假如程序异常退出,而缓冲区中日志大小又没达到批量写入的条件,这部分日志就可能丢弃了。
怎么办呢?
程序异常有两种:一种是能捕获的可控异常,比如 Golang 中数组越界触发 panic;一种是无法捕获的不可控异常,比如 Golang 中并发读写未加锁的 map。
这两种异常下,如何尽可能将缓冲区中的日志写入日志文件呢?
对于第一种情况,通常是捕获异常,在退出程序前执行实例代码中的 Close 函数将日志写入到日志文件。对于第二种情况,我们可以采用定时器,定时将缓冲区中的数据写入到日志文件中,比如定时 100 毫秒执行 Flush 函数
本文章内容为转载,如有侵权请联系作者下架。