刚部署开源项目就发现了 Bug,这第一 P 不就有了吗?

写在前面

笔者前段时间机缘巧合下,成为了 nightgale 的贡献者,之后会分享一些关于参加开源的一些经历,个人同名公粽号:Ciusyan,欢迎志合者一起交流啊~

说来也真凑巧,当时想看看夜莺的代码和设计理念,然后就按照文档,将项目部署了起来。在体验它将告警事件发送邮件的功能时,发现了一些异常情况,后续证实,发现是一个 Bug。

这个问题是什么呢?先简单概述下:

  • 问题描述:如果初次 SMTP 配置有问题,即使改成正确的之后,之前的错误配置仍然会打印错误日志。
  • 问题原因:对 Select 语句的使用不当,有死循环分支,导致 Channel 关闭时,无法进入 Select 另外的退出 case。
  • 解决思路:对其死循环进行控制,适当时机可退出循环,并且将 Channel 的 close 行为,改成直接发送信号的形式。

下面,读者可以带着上面的问题概述,跟着我的思路,一起来探索一下,我是如何发现问题,排查问题,并解决问题的。

p.s. 上面的问题,夜莺的版本是:v6.0.1,但当时的最新版本仍未修复这个 Bug

0x00 背景

既然是一个开源的监控告警系统,肯定得体验下他的监控能力和告警能力。 将各环境配置完毕后,监控看板也配置完成了,配置完告警规则后,到达阈值后,能够及时报警。

但是在配置将报警事件发送至邮件时,不能成功。后面通过一系列排查,发现了一个系统 Bug 先跟着我的思路,来还原一下现场。

0x000 还原现场

先还原当时的操作是:

  1. 配置告警 SMTP 设置。
SMTP 配置.png
  1. 更改报警规则
通知媒介选择 email .png
  1. 将人员设置正确的邮箱并且配置到对应的告警组:
为用户配置邮箱、加入团队(报警组).png

当这些前置操作配置完成后,去触发告警测试。然后出现了一些问题。下面是复现问题的过程。

0x001 问题复现

当时触发告警后,并不能将邮件发送至对应的邮箱。 然后下面就开始了长时间的观察日志过程,跟随我的思路,看我怎么将这个问题复现。

  • 原来是配置配错了,认证不通过。
配置出错,发送失败.png

配置有问题,那就去改配置咯。

  • 期间又改配置,又改错了(故意再次配错,之后会说明一个问题)
再次配置错误.png

可以看到上面的日志,原先那条报错还在继续,改配置后,因为配置是错误的,又多了新的配置。 既然配置又配错了,那就再次修改,尽量别又配错了!!!

  • 再次修改 smtp 配置,这次修改为正确的。
配置正确,发送成功.png

可以看到,将配置修改正确后,触发报警、恢复事件,都能够正常的推送邮件。发送邮件成功,打印的日志也是正常的。 但是我们也可以看到,这条日志周围,还是有很多错误日志,(还是刚刚配置错误的那几次触发的,然后还在不断的重试。) SMTP 的配置也配置正确了,告警也恢复了。应该万事大吉了?

  • 再看日志,怎么刚刚那些错误还是存在啊
告警已经治理了,配置也正确了,为什么还有错误?.png

而且一直存在,明明现在没有告警,配置也没有错误,为什么还是一直有错误在报呢? 而且是每秒报一次,依次有好几条 ERROR 的 email_sender 的错误。 无果,没法解决。

  • 只能被迫重启服务
被迫重启服务.png
  • 系统恢复正常
系统恢复正常.png

在重启服务后,再次查看日志,一切正常。 并且后续报警,也能正常发送邮件。

0x002 现象总结

当复现完问题后,其中有什么问题呢?我们从上面问题的角度来简单总结一下:

  1. SMTP 配置错误,触发告警时,不能接受到告警信息,并且后台每隔一秒,就会产生一条 email_sender 的错误信息。
  2. SMTP 再次配置错误,触发告警时,不能接受告警信息,并且后台每隔一秒,就会产生好几条 email_sender 的错误信息。
  3. SMTP 配置成功正确,触发告警时,能正常收到告警信息,但是在告警恢复后,每隔一秒,后台还是一直出现 email_sender 的错误信息。

究竟是什么问题呢?跟着我的思路一起来排查一下。

0x01 排查问题

下面将是看代码排查问题的过程,下面的代码版本,和部署的夜莺版本保持一致,都是:v6.0.1 这个分支。但是经查看,当前最新 main 分支代码也差不多,应该也有同样的问题。

0x010 问题源头&源码

  • 定位问题源头
根据日志,定位错误源头.png

可以看到,这段代码是用来获取 SMTP 客户端实例的。 如果连接失败,会记录错误日志,并在等待一秒后重试,直到成功连接为止

这里的逻辑是持续尝试建立连接,直到成功获取一个可以用于发送邮件的 SMTP 客户端连接。 那么在哪里需要获取这样一个 SMTP 客户端呢?其实是在 StartEmailSender 方法中调用的。下面是发送邮件的逻辑:

go 复制代码
// 定义邮件发送通道,用于存放待发送的邮件对象
var mailch chan *gomail.Message
// 标记是否需要重置的开关
var mailQuit = make(chan struct{})

// RestartEmailSender 当配置变更的时候,重置邮件发送客户端
func RestartEmailSender(smtp aconf.SMTPConfig) {
	// 去退出上一次的 go StartEmailSender
	close(mailQuit)
	// 给下一次使用
	mailQuit = make(chan struct{})
	StartEmailSender(smtp)
}

// StartEmailSender 具体发送邮件
func StartEmailSender(smtp aconf.SMTPConfig) {
	mailch = make(chan *gomail.Message, 100000)

	conf := smtp

	if conf.Host == "" || conf.Port == 0 {
		logger.Warning("SMTP configurations invalid")
		return
	}
	logger.Infof("start email sender... %+v", conf)

	d := gomail.NewDialer(conf.Host, conf.Port, conf.User, conf.Pass)
	if conf.InsecureSkipVerify {
		d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
	}

	var s gomail.SendCloser
	var open bool
	var size int

	// 开始监听,准备发送消息
	for {
		select {
		case <-mailQuit:
			return
		case m, ok := <-mailch:
			if !ok {
				return
			}

			if !open {
				// 第一次进来,会去获取发送邮件的客户端
				s = dialSmtp(d)
				open = true
			}

			// 来到这里,肯定有客户端 s 了,去发送邮件
			if err := gomail.Send(s, m); err != nil {
				logger.Errorf("email_sender: failed to send: %s", err)

				// close and retry
				if err := s.Close(); err != nil {
					logger.Warningf("email_sender: failed to close smtp connection: %s", err)
				}

				// 发失败了,又获取一遍客户端,再发一遍
				s = dialSmtp(d)
				open = true

				if err := gomail.Send(s, m); err != nil {
					logger.Errorf("email_sender: failed to retry send: %s", err)
				}
			} else {
				logger.Infof("email_sender: result=succ subject=%v to=%v", m.GetHeader("Subject"), m.GetHeader("To"))
			}

			size++

			if size >= conf.Batch {
				// 发送多少次邮件后,之后就得重新获取 客户端了,
				if err := s.Close(); err != nil {
					logger.Warningf("email_sender: failed to close smtp connection: %s", err)
				}
				open = false
				size = 0
			}

		// Close the connection to the SMTP server if no email was sent in
		// the last 30 seconds.
		case <-time.After(30 * time.Second):
			// 超时控制
			if open {
				if err := s.Close(); err != nil {
					logger.Warningf("email_sender: failed to close smtp connection: %s", err)
				}
				open = false
			}
		}
	}
}

这段代码的逻辑比较简单,核心点事使用了 Channel 来解耦邮件生产和发送。并且对其做了超时的控制以及刷新配置的逻辑。下面简单解释一下,可跳过

  1. 全局变量
  • mailch: 存放待发送邮件的通道。
  • mailQuit: 控制邮件发送服务停止的信号通道。
  1. RestartEmailSender
  • 关闭 mailQuit 通道以停止当前邮件发送服务。
  • 重新创建 mailQuit 通道。
  • 启动邮件发送服务 (StartEmailSender)。
  1. StartEmailSender
  • 创建或重新初始化 mailch 通道。
  • 验证SMTP配置,若无效则终止。
  • 配置并初始化SMTP拨号器 (gomail.Dialer)。
  • 进入监听循环:
    • 如果收到 mailQuit 信号,退出服务。
    • 如果从 mailch 通道接收到邮件:
      • 如果没有打开SMTP连接,通过 dialSmtp 打开。
      • 发送邮件,如果失败,尝试重发。
      • 检查发送批量,达到指定数量后重置SMTP连接。
    • 如果30秒内无邮件发送,关闭SMTP连接。

0x011 问题分析

既然这是告警相关的逻辑,那么先简单看看告警模块整体的架构。

0x0110 告警架构

下面告警模块,对接的通知媒介,将以邮件模块举例,不必在意其中省略的许多细节:

其中核心点是:定期根据告警规则,查询对应的数据,如果需要告警,就会去生产 Event 事件 (告警/恢复) 并且将其时间放置在一个 Queue 队列里面,等待消费。 消费事件时,根据对应告警规则的通知媒介,去执行具体的通知。比如上方的通知媒介是 Email,然后去 Process 这个事件。

当到达了 Email 通知媒介后,他会将这个事件携带的通知信息,打包成 Message 邮件,发送至 Channel 里面,等待消费。 消费消息时,根据这个消息的原信息,获取对应的客户端,发送对应的消息。

对告警模块有一个大致的了解后,来分析原因了。

0x0111 问题原因

在消费 Email Message 的协程中,会一直监听三个 Channel。

现象一:配置错误,发送失败,每秒产生错误日志

当产生告警信息后,会将其发送告警的 Massage 进入 mailch,消费协程就会执行对应的逻辑。 首先第一步就是去获取 SMTP 客户端,在发送前,还会进行 dial 认证,但由于我们的配置是错误的,肯定会失败。 但由于是无限重试,重试的频率是 1s。对于这样的场景,重试其实是没有意义的,因为配置是错误的,永远不可能成功。所以当前 Goroutine 就会一直被 "阻塞" 到这里,并且每隔一秒就记录一条日志。

上述描述,如下图所示:

既然配置错了,正常思维就是去修改配置。

现象二:修改错误配置,发送失败,错误日志增多

当修改配置后,会一步步调用到 RestartEmailSender 方法,执行这个方法时,会重新开启一个 Goroutine, 并且它会去关闭 mailQuit 这个管道,想要退出之前消费 Massage 的那个协程。 然后新建一个 mailQuit 管道,又去调用消费逻辑,也就是将当前协程,变成新的消费 Massage 的消费者。

但是原先那个协程由于配置错误,一直在 "阻塞" 的重试。即使当前协程将 mailQuit 协程关闭,旧的消费者协程也不会退出 。然后又在当前协程重新调用了消费的逻辑,即又会在当前协程开启监听,等待消费 Message 发送邮件。

当系统又产生报警的时候,当前协程又会去获取客户端,进行 dial 操作。但好巧不巧,又配错了,结果呢?也和上面的表现是一样的,当前协程,又会 "阻塞" 在这里,去无限重试。

上述如下图所示:

现象三:修改正确配置,发送成功,仍持续报错

当了解了前面两个现象产生的原因,其实这里就好理解了。 前面有两个 Goroutine,还一直 "阻塞" 在每隔一秒的重试中,这里又修改了配置,又会开启一个新的协程去调用消费逻辑。所以现在系统的现状是:

由于这一次 SMTP 的配置被调整正确了,所以当再次产生告警的时候,能够发送成功。 并且在产生了「恢复」Event 的时候,也能成功接收到通知。 那系统中为什么仍然会持续的产生错误日志呢?其实这时候的错误日志,是 "旧消费者的协程" 产生的,和当前协程无关。

当分析完现象产生的原因后,当时又发现了一个问题。

引申问题:每次修改配置的时候,会丢掉还未被消费的数据

p.s. 对于这个延伸问题,是基于当前的 v6.0.1 版本。同期的 lastest 版本,已经修复了,但前面分析的几个问题,依旧存在,并由笔者修复合并。

为什么这么说呢?当用户进行配置修改的时候,就会调用 RestartEmailSender() 方法。如下图所示:

当调用了这个方法,我们可以知道,他会重新调用消费的逻辑。而消费逻辑的第一步,就是去构建一个全新的 mailch:

如上图所示,当旧的 mailch 被丢掉后,若旧的 mailch 还有没被消费的 Message 数据,也会一同被丢掉。

当然,可能由于监控告警,对于时间很敏感。如果都积压了很多消息没有被发送,当过了一定时间后再发送这种 "过期告警",其实意义可能也不是特别大。 但这也是设计的时候,其中一个缺陷所在,而且也可能会出现那种临界时刻,即丢掉的数据也是里当前时间不久远的。

0x0112 问题总结

分析了这么久,先对遇到的问题,做一个总结:

  1. 无限重试,导致问题协程一直空转,表现出 "阻塞" 的状态。
  2. 修改配置后开启新的消费协程后,无法退出旧的消费协程。
  3. 修改配置,可能会导致未被消费的 Message 丢失。

0x0112 代码复现

上面提到的一些现象和问题,可能和这个业务场景相关。我下面将简单实现两段逻辑,可以帮助你快速理解上面提到的几个问题。直接运行起来,观察输出的日志,即可验证:

  • 模拟更换 channel,数据丢失
go 复制代码
// @Author: Ciusyan 4/14/24

package sender

import (
	"sync"
	"testing"
	"time"
)

// 模拟数据丢失的问题
func TestChannel(t *testing.T) {
	// 模拟的 channel,中途会重建
	ch := make(chan int, 100)

	// 先将缓冲区塞满,模拟等待消费的数据。
	for i := 1; i <= 100; i++ {
		ch <- i
	}

	// 每隔一秒就消费一个数据。
	go func() {
		for {
			select {
			case num := <-ch:
				t.Log(num)
				time.Sleep(time.Second)
			}
		}
	}()

	// 3s 后,模拟被重启:重新设置 channel
	go func() {
		time.Sleep(3 * time.Second)
		ch = make(chan int, 100)
	}()

	// 过 5s 后,将其 channel 关闭
	time.Sleep(5 * time.Second)
	close(ch)

	// 然后看看 ch 里面的数据是否被丢掉了!
	//	观察打印结果,数据已经被丢掉了~
	for num := range ch {
		t.Log(num)
	}
}
  • 模拟无限重试,协程 "阻塞" 问题
go 复制代码
// 模拟无限重试,协程 "阻塞" 问题
func TestGoroutine(t *testing.T) {

	var ch chan int
	quit := make(chan struct{})

	start := func(idx int) {
		t.Logf("start: %d", idx)
		ch = make(chan int, 100000)
		for {
			inner := 0
			select {
			case <-quit:
				t.Logf("退出了 start ch:%d", idx)
				return
			case num := <-ch:
				inner++
				// 模拟去进行前置获取
				for {
					nowStr := time.Now().Format("15:04:05")
					// 假设失败了,一直重试
					t.Logf("[%s] 获取第 %d 批,消费第 %d 个内容: %d", nowStr, idx, inner, num)
					time.Sleep(time.Second)
				}
			case <-time.After(5 * time.Second):
				// 超时控制
				t.Logf("超时,退出:%d", idx)
			}
		}

	}

	// 模拟重新去获取对应的操作。
	restart := func(idx int) {
		t.Logf("restart: %d", idx)
		close(quit)
		quit = make(chan struct{})
		start(idx)
	}

	// 等待这个生产协程退出,就退出整个程序
	var wg sync.WaitGroup
	wg.Add(1)

	// 模拟一分钟,自行观察日志
	producer := func() {
		defer wg.Done()
		// 生产内容,每隔一秒就生成一个消息
		for i := 1; i <= 60; i++ {
			ch <- i
			time.Sleep(1 * time.Second)
		}
	}

	// 消费
	go start(1)
	// 确保 ch 准备好了
	time.Sleep(time.Millisecond * 500)

	// 生产
	go producer()

	// 模拟修改了三次配置
	time.Sleep(3 * time.Second)
	go restart(2)
	time.Sleep(3 * time.Second)
	go restart(3)
	time.Sleep(3 * time.Second)
	go restart(4)

	// 等待生产数据的协程退出
	wg.Wait()
}

对于上面两段逻辑,不算复杂,并且也只是简单验证,不用关心具体的代码风格,规范啥的。 运行起来,快速验证问题即可!

啰嗦了这么久,怎么解决呢?其实还挺简单,最后来看看是如何解决的吧。

0x02 解决方案

我们对问题进行分析,可以发现,其实核心点是:对于 select 监听的某一个 case,其中有无限循环的逻辑,占用了 select,无法进入其他 case。

所以,我们只需要将其死循环,可以有退出条件即可解决。什么时候退出呢?只要 mailQuit 有信号时,退出即可。因为当 mailQuit 收到信号时,现在再来 dail 也没有太大的意义了。 那么我们对重试 dail 的协程,也监听 mailQuit 的信号:

go 复制代码
func dialSmtp(d *gomail.Dialer) gomail.SendCloser {
	for {
		select {
		case <-mailQuit:
			// Note that Sendcloser is not obtained below,
			// and the outgoing signal (with configuration changes) exits the current dial
			return nil
		default:
			if s, err := d.Dial(); err != nil {
				logger.Errorf("email_sender: failed to dial smtp: %s", err)
			} else {
				return s
			}
			time.Sleep(time.Second)
		}
	}
}

经过这样改造后,正常情况下,能够一直去重试,避免因为网络波动导致的 dail 失败。 但当收到 mailQuit 发出的信号后,也能够同步的去退出这个无限循环。

监听了信号的接收,那么什么时候发送信号呢? 以前试图通过 close 的方式来告知监听其管道的 case,执行后续的退出逻辑。 但若刚好在 close 的时候,select 还处于被占用状态,那么当重建 mailQuit 后,依旧会有问题。 所以,我们将 close mailQuit,更改为直接发送信号的方式。

go 复制代码
func RestartEmailSender(smtp aconf.SMTPConfig) {
	// Notify internal start exit
	mailQuit <- struct{}{}
	startEmailSender(smtp)
}

写在最后

好了,至此文章就结束了,其实问题并不复杂,解决思路也不复杂。只是按照自己的思路,一步步的让你:发现问题、排查问题、解决问题。

你看,这是不是和我们的生活很像?不断的发现问题,不断的排查问题,不断的解决问题。周而复始,循环往复。技术如此,人生更当然!

如果对你产生了一定的思考,可以来公粽号椒牛鸭:Ciusyan~

相关推荐
大梦百万秋9 分钟前
Spring Boot实战:构建一个简单的RESTful API
spring boot·后端·restful
斌斌_____37 分钟前
Spring Boot 配置文件的加载顺序
java·spring boot·后端
路在脚下@1 小时前
Spring如何处理循环依赖
java·后端·spring
vvw&1 小时前
Docker Build 命令详解:在 Ubuntu 上构建 Docker 镜像教程
linux·运维·服务器·ubuntu·docker·容器·开源
海绵波波1072 小时前
flask后端开发(1):第一个Flask项目
后端·python·flask
小奏技术2 小时前
RocketMQ结合源码告诉你消息量大为啥不需要手动压缩消息
后端·消息队列
m0_748241704 小时前
前端学习:从零开始做一个前端开源项目
前端·学习·开源
AI人H哥会Java4 小时前
【Spring】控制反转(IoC)与依赖注入(DI)—IoC容器在系统中的位置
java·开发语言·spring boot·后端·spring
凡人的AI工具箱4 小时前
每天40分玩转Django:Django表单集
开发语言·数据库·后端·python·缓存·django
奔跑草-4 小时前
【数据库】SQL应该如何针对数据倾斜问题进行优化
数据库·后端·sql·ubuntu