1.问题来历
前几天在产线进行大方直播功能压测时,遇到心跳接口TPS从平时的单机16000+忽然下降到了集群TPS(8台机器)才200多一点,功能直接下降了上百倍。
同时运维同学还发现以下现象:
- Nginx上在转发此接口时有发现个别恳求上游服务60s超时,回来504错误
- 从对机器资源的体系监控上看,8台机器中有一台内存显着比其它高,而且内存看起来只升不降
从这几个现象根本能估测,liveserver-555ddc587b-8rlm7(10.70.210.20)这台服务器内部应该是呈现了堵塞。
2.堵塞剖析
2.1接口逻辑
心跳接口的事务运转流程如下图,能够试着剖析下哪个当地有或许引起堵塞:
2.2音讯行列堵塞?
以下几点现象根本排除了音讯行列堵塞:
- 从批量处理心跳的异步使命运转状况来看,音讯行列里根本没有音讯;
- 从缓冲行列的巨细参数来看,音讯行列的空间足够大(一百万),即使整个压测TPS都打到一台上,缓冲行列也打不满;
func Init() {
queue = NewQueue(1000000, 5000, updateHeartbeat)
}
func NewQueue(chanSize uint64, maxBatchNum int, f func(message []interface{})) *Queue {
……
}
- 从代码层来看,行列写操作本身不会堵塞,假如满了会直接丢音讯;
2.3 MemCache死锁?
在一次缓存操作进程中,MemCache共三处用到了锁,别离是:
- Get操作:用到了大局Cache的读锁
- Set操作:用到了对大局Cache的写锁
- Update操作:用到了对更新操作办理的互斥锁,防止同一数据的并发更新
2.4 死锁的原因
从控制台日志中找到三块仓库信息:
与心跳有显着相关的应该是第2部分仓库,panic方位正好是MemCache操作的部分:
- Get操作发现MemCache中没有目标,所以履行第40行update操作加载并更新缓存;
- 第40行的update办法调用主要是对多线程操作的并发办理,终究加载数据是走入参f所封装的H.LiveStatus()办法;
- 而H.LiveStatus()办法发生了panic;
经过代码剖析,不难发现问题:
- 加载数据的事务办法f发生panic, 导致item.Done()办法没有履行,这期间履行Item.Wait的一切线程都变成了永远堵塞;
- 因为这个反常使命item没有从大局变量updating中删除,后续相同key的一切update操作都堵塞在了item.Wait;
- 终究,MemCache堵塞了这个服务;
对外表现为,整场会议的一切心跳恳求都被堵塞,所以内存只升不降,接口大量60s呼应超时,压测机被超时恳求堵塞,终究表现为TPS很低;
3.panic追溯
3.1 仓库剖析
死锁原因已经找到,那导致死锁的事务panic是怎么发生的呢? 从具体仓库结合代码来看,是事务办法履行进程顶用JSON序列化数据引发了panic, 代码行如下:
JSON序列化为何panic暂时不知道,不过能够试着看看它崩在encoding/json的哪一行代码,代码溃散的时分正在履行什么操作
单从这一行代码还是看不出来什么,能够从panic函数调用仓库来试着估测下json序列化的整个进程,以此来判别1033行这个string函数在整个序列化进程中扮演的人物。
经过代码仓库能够知道,json序列化的进程其实就是从外到内深度遍历遍历每个目标/字段的进程,序列化进程其实也反映了被序列化目标的内部结构,契合这个结构的字段应该是StreamInfo目标中一个字符串字段。
这里咱们要结合下报错原因:invalid memory address or nil pointer dereference
- 字符串是值目标,空指针是说不通的
- 字符串能够看作是一个[]byte, 假如说遍历这个[]byte呈现不合法内存拜访,那等所以说拜访了不属于这个字符串的内存
3.2 字符串赋值是原子操作吗?
字符串会呈现不合法内存拜访吗?看网上一个比较撒播比较多的一个例子。
package main
import (
"fmt"
"time"
)
const (
FIRST = "WHAT THE"
SECOND = "F*CK"
)
func main() {
var s string
go func() {
i := 1
for {
i = 1 - i
if i == 0 {
s = FIRST
} else {
s = SECOND
}
time.Sleep(10)
}
}()
for {
if s == "WHAT" {
panic(s)
}
fmt.Println(s)
time.Sleep(10)
}
}
运转这段程序的成果:
从这段程序的测验成果来看,字符串赋值并不是线程安全的,实际上字符串内部结构也决议了字符串赋值并非原子操作:
type stringStruct struct {
str unsafe.Pointer
len int
}
3.3 或许原因估测
回过头来看咱们的事务代码,直接对字符串字段进行的赋值的当地并没有找到,不过里边有对整个结构体目标赋值的当地:
猜测比较或许的原因:
- 切片内部就是数组,一块元素是固定巨细的连续内存,切片元素按下标赋值能够理解为对指定内存区域按字段偏移逐一向内存写数据;
- 假如字段是简单类型(如整型),则地址偏移后直接写操作,假如字段是杂乱类型(如结构体),则需要递归展开每个字段逐一读写;
- 字符串内部是一个结构体类型,或许会呈现写完Data还没来得及写Len时,其它线程正好进行读操作,将不完整的stringStruct读走;
- 不完整的stringStruct被读走后,读操作线程就会按读到的Len对Data指向的内存地址进行拜访,从而导致非预期的内存读写操作;
3.4 结构体赋值验证
type A struct {
age int
name string
}
func main() {
s := []A{
A{10, "zhangsan"},
A{20, "lisi"},
}
go func() {
for {
s[0], s[1] = s[1], s[0]
time.Sleep(10)
}
}()
for {
if s[0].name == "zhan" {
panic(s[0].name)
}
fmt.Println(s)
time.Sleep(10)
}
}
运转这段程序的成果:
这段程序根本证明了咱们的猜测:
- 结构体变量的赋值是一个杂乱的进程,里边分化成了许多字段的别离赋值的小过程;
- 在结构体赋值操作履行的时分,假如同一时间有线程去并发的读取,读到的值是无法预期的;
**结论:**多线程上同享目标时,只能同享读操作; 一旦涉及到写操作,最好给每个线程生成独立的目标,或者加锁维护;
参考资料
- 聊聊 Go 并发安全 jishuin.proginn.com/p/763bfbd5d…
- Go服务灵异panic segmentfault.com/a/119000002…