目录
背景
业务上使用生产-消费模式处理第三方上报的异步消息;job服务作为消费者,以常驻线程的方式,统一消费各个topic的消息(业务使用kafka做消息队列),然后通过同步调用业务接口使消息生效,数据流大致如下:
一条异常消息引发的惨案
一天中午,在uat环境出现队列消息堆积、数据无写入的现象,快速定位到job-service服务容器一直处于重启中,无法正常工作;通过重启日志,发现一个topic的消费者在消费某条消息时panic,导致整个job服务宕机,虽然discovery服务检测到心跳异常后不断重启,但是由于kafka的消费特性(没有commit的消息下次仍然会消费),导致每次重启都会消费到这条消息,继续panic,无限循环。。。
从而仅仅因为这一个topic的一条异常消息,导致整个服务的消费宕机(服务共消费了十多个topic的信息,绝大部分是业务核心消息),对于生产环境来说实在是可怕至极
事故中的反思
虽然(幸好)只是一次uat环境事故,也可以看出设计的缺陷;最起码,消息之间应该独立,改进方案这里不再过多赘述,但是触发整个系统宕机的原因颇引起我的重视 — panic
panic
是golang语言所特有的功能,不像其他语言如java、python是通过exception的方式抛出错误,go的panic会触发整个程序的宕机,当然可以通过recover()
来核销panic是系统可以正常运行,但是从我自己、身边同学、面试中面试者的反馈来看,很多同学对如何解决panic的方式不是特别清晰
panic触发及恢复场景
我通过查阅文档和实验,明确了各种情况下panic后,系统的反应情况
主线程触发panic
// 基础函数-简单for循环
func goroutineNormal(caller string) {
for {
tm := time.NewTicker(time.Second * 1)
select {
case <-tm.C:
fmt.Printf("%s...\n", caller)
}
}
}
// 基础函数,主线程panic函数后执行
func laterTodo() {
goroutineNormal("laterTodo")
}
// 测试函数
// 场景一:无recover
// 场景二:有recover
func testPanicInMain() {
defer func() {
if err := recover(); err != nil {
fmt.Println("recover...")
}
}()
go goroutineNormal("testPanicInMain")
time.Sleep(time.Second * 3)
panic(errors.New("stop test1"))
fmt.Println("after panic")
}
func main() {
testPanicInMain()
laterTodo()
}
场景一:主线程无recover
testPanicInMain...
testPanicInMain...
panic: stop test1
goroutine 1 [running]:
demo/foundation.testPanicInMain()
/Users/liushi/Projects/go/golang-demo/foundation/panic.go:17 +0xce
demo/foundation.TestPanic()
/Users/liushi/Projects/go/golang-demo/foundation/panic.go:62 +0x22
main.main()
/Users/liushi/Projects/go/golang-demo/main.go:14 +0x20
Process finished with exit code 2
- 主线程崩溃,panic后所有逻辑不执行,异步协程退出
场景二:主线程有recover
testPanicInMain...
testPanicInMain...
recover... // recover后,系统正常
testPanicInMain...
laterTodo...
testPanicInMain...
laterTodo...
testPanicInMain...
- 主线程恢复,异步协程无影响;
- 触发panic的函数内部:触发panic代码后的逻辑不执行
- 触发panic的函数外部:主函数其他逻辑正常执行
异步goroutine触发panic
// 测试函数
// 场景一:异步协程无recover
// 场景二:异步协程有recover
func goroutineWithPanic1() {
defer func() {
if err := recover(); err != nil {
fmt.Println("recover in goroutineWithPanic1...")
}
}()
go goroutineNormal("goroutineWithPanicAsync")
panic(errors.New("goroutineWithPanic1 error"))
goroutineNormal("goroutineWithPanic1")
}
// 测试函数-
func goroutineWithPanic2() {
goroutineNormal("goroutineWithPanic2")
}
// 测试函数
// 场景一:主线程函数无recover
// 场景二:主线程函数有recover
func testPanicInGoroutine() {
defer func() {
if err := recover(); err != nil {
fmt.Println("recover...")
}
}()
go goroutineWithPanic1()
go goroutineWithPanic2()
fmt.Println("after panic")
}
func main() {
testPanicInGoroutine()
laterTodo()
}
场景一:主线程无recover,异步协程无recover
after panic
panic: goroutineWithPanic1 error
goroutine 19 [running]:
demo/foundation.goroutineWithPanic1()
/Users/liushi/Projects/go/golang-demo/foundation/panic.go:39 +0xc0
created by demo/foundation.testPanicInGoroutine
/Users/liushi/Projects/go/golang-demo/foundation/panic.go:27 +0x39
Process finished with exit code 2
- 程序崩溃,其他所有异步goroutine奔溃(包括goroutineWithPanicAsync和goroutineWithPanic2)
场景二:主函数无recover,异步协程有recover
after panic
recover in goroutineWithPanic1... // recover
goroutineWithPanicAsync...
goroutineWithPanic2...
laterTodo...
laterTodo...
goroutineWithPanic2...
goroutineWithPanicAsync...
- 主线程正常执行,其他异步协程正常执行
- 触发panic的异步内部:
- 触发panic行后的代码逻辑不执行
- 已执行的逻辑生效,已启动的异步协程继续执行
- 异步协程中启动的协程挂在主线程下,触发panic的异步协程退出(???
场景三:主函数有recover,异步协程无recover
after panic
panic: goroutineWithPanic1 error
goroutine 6 [running]:
demo/foundation.goroutineWithPanic1()
/Users/liushi/Projects/go/golang-demo/foundation/panic.go:39 +0xc0
created by demo/foundation.testPanicInGoroutine
/Users/liushi/Projects/go/golang-demo/foundation/panic.go:27 +0x5b
Process finished with exit code 2
- 主函数崩溃,其他所有goroutine崩溃
- 注意,panic无法跨协程传递,主线程的recover无法恢复子协程的panic,相关知识可以看下
defer
函数原理传送门
- 注意,panic无法跨协程传递,主线程的recover无法恢复子协程的panic,相关知识可以看下
总结
了解了golang再触发和恢复panic的原理之后,触发上述问题的根因和解决方案也明朗起来:
- 原因:异步协程消费某条消息,触发panic,且异步协程没有recover,导致整个服务崩溃
- 解决方案:增加异步协程的recover,以消息为单位,异常消息触发panic后不影响常驻线程消费下一条消息,适当增加错误日志和异常消息的报警