一次错误使用 go-cache 导致出现的线上问题
话说一个美滋滋的上午, 突然就出现大量报警, 接口大量请求都响应超时了.
排查过程
- 查看服务器的监控系统, CPU, 内存, 负载等指标正常
- 排查日志, 日志能够响应的结果也正常. request.log 中响应时长高达数秒
- 查看数据库, codis 监控, 各项指标正常
不得已, 只能打开线上 pprof 查看 Go 相关参数是否正常. 果真一下子就找到问题发生的原因

这是当时线上 pprof 的截图, 发现 40 多万 goroutine 都阻塞在 go-cache 的 Set 函数上. 更准确的说 40 多万 goroutine 在发生很严重的锁竞争. 这就让人觉得很意外了.
幸好当时在压测接口的时候, 为了避免 go-cache 的影响结果的影响, 引入了一个配置项来控制是否开启 go-cache, 于是立马线上关闭 go-cache, 接口响应恢复正常.
问题来了
虽说问题解决了, 但是是由于什么原因造成的呢?
- 为什么 go-cache 会发生这么严重的锁竞争 ?
- 是由于 go-cache 有代码 bug 吗 ?
- 如何才能稳定复现呢 ?
go-cache 源码剖析
为了探究这个 bug 引起的原因, 我将整个 go-cache的源码读了一遍, 其实 go-cache 相对于 freecache, bigcache 还是相对简单许多.
type cache struct {
defaultExpiration time.Duration
items map[string]Item
mu sync.RWMutex
onEvicted func(string, interface{})
janitor *janitor
}
从结构体上, go-cache 主要还是由 map + RWMutex 组成.
Set -- go-cache 最重要的函数
// Add an item to the cache, replacing any existing item. If the duration is 0
// (DefaultExpiration), the cache's default expiration time is used. If it is -1
// (NoExpiration), the item never expires.
func (c *cache) Set(k string, x interface{}, d time.Duration) {
// "Inlining" of set
var e int64
if d == DefaultExpiration {
d = c.defaultExpiration
}
if d > 0 {
e = time.Now().Add(d).UnixNano()
}
c.mu.Lock()
c.items[k] = Item{
Object: x,
Expiration: e,
}
// TODO: Calls to mu.Unlock are currently not deferred because defer
// adds ~200 ns (as of go1.)
c.mu.Unlock()
}
Set 需要三个参数: key, value, d(过期时间). 如果 d 为 0, 则使用 go-cache 默认过期时间, 这个默认过期时间是go-cache.New()时设置的. 如果 d 为 -1, 那么这个 key 不会过期
实现过程:
- RWMutex.Lock
- 设置过期时间, 将 value 放入 map 中
- RWMutex.Unlock
还有另外几个衍生函数: SetDefault, Add, Replace, 这里就不做具体介绍
Get go-cache 最重要的函数
func (c *cache) Get(k string) (interface{}, bool) {
c.mu.RLock()
// "Inlining" of get and Expired
item, found := c.items[k]
if !found {
c.mu.RUnlock()
return nil, false
}
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, false
}
}
c.mu.RUnlock()
return item.Object, true
}
- RWMutex.RLock
- 判断是否存在
- 判断是否过期
- RLock.RUnlock
Increment/Decrement
go-cache 对数值类型的值是比较友好的, 提供大量函数 Increment, IncrementFloat等函数, 能够轻松对内存中的各种数值进行加减, 其实现也简单
func (c *cache) IncrementUint16(k string, n uint16) (uint16, error) {
c.mu.Lock()
v, found := c.items[k]
if !found || v.Expired() {
c.mu.Unlock()
return 0, fmt.Errorf("Item %s not found", k)
}
rv, ok := v.Object.(uint16)
if !ok {
c.mu.Unlock()
return 0, fmt.Errorf("The value for %s is not an uint16", k)
}
nv := rv + n
v.Object = nv
c.items[k] = v
c.mu.Unlock()
return nv, nil
}
- RWMutex.Lock
- 判断某个 key 在 map 中是否存在
- 判断是否这个 key 是否过期
- 对这个值加 n
- RWMutex.Unlock
落盘/恢复方案
go-cache 自带落盘/恢复方案, 将内存中的值进行落盘, 同时将文件中的内容恢复. 不过我感觉这个功能挺鸡肋的, 没必要在生产环境中使用. 这里就不做过多的介绍了.
go-cache 过期清理方案
func (c *cache) DeleteExpired() {
log.Printf("start check at:%v", time.Now())
var evictedItems []keyAndValue
now := time.Now().UnixNano()
c.mu.Lock()
for k, v := range c.items {
// "Inlining" of expired
if v.Expiration > 0 && now > v.Expiration {
ov, evicted := c.delete(k)
if evicted {
evictedItems = append(evictedItems, keyAndValue{k, ov})
}
}
}
c.mu.Unlock()
for _, v := range evictedItems {
c.onEvicted(v.key, v.value)
}
}
func (j *janitor) Run(c *cache) {
ticker := time.NewTicker(j.Interval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-j.stop:
ticker.Stop()
return
}
}
}
func runJanitor(c *cache, ci time.Duration) {
j := &janitor{
Interval: ci,
stop: make(chan bool),
}
c.janitor = j
go j.Run(c)
}
func newCacheWithJanitor(de time.Duration, ci time.Duration, m map[string]Item) *Cache {
c := newCache(de, m)
// This trick ensures that the janitor goroutine (which--granted it
// was enabled--is running DeleteExpired on c forever) does not keep
// the returned C object from being garbage collected. When it is
// garbage collected, the finalizer stops the janitor goroutine, after
// which c can be collected.
C := &Cache{c}
if ci > 0 {
runJanitor(c, ci)
runtime.SetFinalizer(C, stopJanitor)
}
return C
}
可以看到 go-cache 每过一段时间(j.Interval, 这个值也是通过 go-cache.New 设置), 就会启动清理工作.
清理时原理:
- RWMutex.Lock()
- 遍历整个map, 检查 map 中的 value 是否过期
- RWMutex.Unlock()
同时, 还利用了 runtime.SetFinalizer 在 go-cache 生命周期结束时, 主动完成对过期清理协程的终止
源码分析总结
遍览整个 go-cache 源码, 会发现 go-cache 完全靠着 RWMutex 保证数据的正确性.
考虑下面的问题:
- 当 go-cache.New() 时设置的定时清理的时间过长, 同时 Set 的 key 的过期时间比较长, 这样会不会导致 go-cache.map 中的元素过多?
- 会不会当清理启动时, 锁定了 go-cache.map (注意这个时候是写锁), 由于 go-cache.map 中元素过多, 导致 map 一直被锁定, 那么这个时候所有的 Set 函数是不是就会产生 Lock 竞争?
- 使用 go-cache 的时候, 当某个接口的 QPS 很高, 程序里由于使用问题, 将某些不该往 go-cache 存的 value 也存了进去, 那么会不会导致 Set 之间的 Lock 竞争呢?
场景还原
利用下面的程序可以轻松还原上面的问题场景. 上面提出的问题, 都会造成 go-cache lock 竞争. 这里利用 pprof 查看程序的指标
var goroutineNums = flag.Int("gn", 2, "goroutine nums")
func main() {
flag.Parse()
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
rand.Seed(time.Now().Unix())
lc := cache.New(time.Minute*5, time.Minute*2)
log.Printf("start at:%v", time.Now())
aaaKey := "aaa:%d:buy:cnt"
log.Println("set run over")
for i := 0; i < *goroutineNums; i++ {
go func(idx int) {
for {
key := fmt.Sprintf(aaaKey, rand.Int())
newKey := fmt.Sprintf("%s:%d", key, rand.Int())
v := rand.Int()
lc.Set(newKey, v, time.Millisecond)
}
}(i)
}
// 查看 go-cache 中 key 的数量
go func() {
ticker := time.NewTicker(time.Second)
for {
select {
case <-ticker.C:
log.Printf("lc size:%d", lc.ItemCount())
}
}
}()
select {}
}
模拟接口高QPS
./go-cache-test -gn 2000
2020/03/12 00:32:33 start at:2020-03-12 00:32:33.949073 +0800 CST m=+0.001343027
2020/03/12 00:32:34 lc size:538398
2020/03/12 00:32:35 lc size:1149109

瞬间就会出现锁竞争
模拟 go-cache 启动清理时的情形
./go-cache-test -gn 2
2020/03/12 00:37:33 start at:2020-03-12 00:37:33.171238 +0800 CST m=+0.001457393
...
2020/03/12 00:40:35 lc size:54750220
2020/03/12 00:40:35 start clear at:2020-03-12 00:40:35.103586 +0800 CST m=+120.005547323
2020/03/12 00:41:51 lc size:33
2020/03/12 00:41:51 lc size:50

会看到当清理 map 的时候, 如果 map 中的数据过多就会造成 Lock 竞争, 造成其他数据无法写入 map
总结
我使用的问题
背景: 某接口 QPS 有点高
- 当时考虑到用户购买状态(这个状态可能随时变化)如果能够在本地缓存中缓存 10s, 那么用户再次点进来的时候能从本地取了, 就造成大量的数据都写入了 map 中
- 由于接口 QPS 比较高, 设置用户购买状态时就可能造成竞争, 造成接口响应超时
go-cache 使用注意点
- 尽量存放那些相对不怎么变化的数据, 适用于所有的 local cache(包括 map, sync.map)
- go-cache 的过期检查时间要设置相对较小, 也不能过小
- 那些高 QPS 的接口尽量不要去直接 Set 数据, 如果必须 Set 可以采用异步操作
- 监控 go-cache 里面 key 的数量, 如果过多时, 需要及时调整参数
资料
一次错误使用 go-cache 导致出现的线上问题的更多相关文章
- 改进动态设置query cache导致额外锁开销的问题分析及解决方法-mysql 5.5 以上版本
改进动态设置query cache导致额外锁开销的问题分析及解决方法 关键字:dynamic switch for query cache, lock overhead for query cach ...
- dubbo多网卡时,服务提供者的错误IP注册到注册中心导致消费端连接不上
使用了虚拟机之后,启动了dubbo服务提供者应用,又连了正式环境的注册中心: 一旦dubbo获取的ip错误后, 这种情况即使提供者服务停掉,目前dubbo没有能力清除这类错误的提供者: (需要修改源码 ...
- CAS (15) — CAS 线上环境 Ehcache Replication 的非稳定重现错误 java.util.ConcurrentModificationException
CAS (15) - CAS 线上环境 Ehcache Replication 的非稳定重现错误 摘要 线上环境在 EhCache Replication 过程中出现 java.util.Concur ...
- Try-Catch包裹的代码异常后,竟然导致了产线事务回滚!
导读:一段被try-catch包裹后的代码在产线稳定运行了200天后忽然发生了异常,而这个异常竟然导致了产线事务回滚.这期间究竟发生了什么?日常在项目过程中该如何避免事务异常?就在这个时候,老板拿着 ...
- HTTP 错误 404.2 - Not Found 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面
详细错误:HTTP 错误 404.2 - Not Found. 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面. 出现环境:win7 + IIS7.0 解决办法 ...
- 关于wcf异常异常信息:由于内部错误,服务器无法处理该请求。有关该错误的详细信息,请打开服务器上的 IncludeExceptionDetailInFaults (从 ServiceBehaviorAttribut
异常信息:由于内部错误,服务器无法处理该请求.有关该错误的详细信息,请打开服务器上的 IncludeExceptionDetailInFaults (从 ServiceBehaviorAttribut ...
- 线上故障排查——drools规则引擎使用不当导致oom
事件回溯 1.7月26日上午11:34,告警邮件提示:tomcat内存使用率连续多次超过90%: 2.开发人员介入排查问题,11:40定位到存在oom问题,申请运维拉取线上tomcat 内存快照dum ...
- (转)HTTP 错误 404.2 - Not Found 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面
详细错误:HTTP 错误 404.2 - Not Found. 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面. 出现环境:win7 + IIS7.0 解决办法 ...
- 解决:HTTP 错误 404.2 - Not Found. 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面
错误重现: 在发布网站的过程中,虽然不是第一次发布了,但是还是遇到了很多的问题.为了以后可以轻松解决此类问题还是积累下来比较好. 问题:HTTP 错误 404.2 - Not Found. 由于 We ...
随机推荐
- DRF 三大认证之身份认证
目录 路由组件补充 三大认证 一.身份认证 1.如何进行身份认证 2.jwt认证规则原理 3.jwt的组成 4.jwt的使用方法 4.1 签发算法 4.2 校验算法 4.3 刷新算法 二.权限认证 三 ...
- # PyComCAD介绍及开发方法
项目地址:https://github.com/JohnYang1210/PycomCAD 1.综述 提到Autocad在工业界的二次开发,VB或者Lisp可能作为常用的传统的编程语言.但是,Py ...
- 第28天学习打卡(Date和Calendar类 基本类型的包装类 集合 增强for循环 )
Date和Calendar类 简介 日期和日历类,用于操作日期相关信息. 构造方法 Date(): 构造一个日期对象,当前系统时间,精确到毫秒. Date(long): 构造一个日期对象,时间为自&q ...
- 基于docker快速搭建hbase集群
一.概述 HBase是一个分布式的.面向列的开源数据库,该技术来源于 Fay Chang 所撰写的Google论文"Bigtable:一个结构化数据的分布式存储系统".就像Bigt ...
- Portainer中文汉化
一.概述 Portainer是Docker的图形化管理工具,提供状态显示面板.应用模板快速部署.容器镜像网络数据卷的基本操作(包括上传下载镜像,创建容器等操作).事件日志显示.容器控制台操作.Swar ...
- 微信小程序日期时间选择器(精确到秒)
<picker mode="multiSelector" value="{{dateTime1}}" bindchange="changeDat ...
- 微信小程序和H5之间相互跳转
1.微信小程序跳转小程序 wx.navigateToMiniProgram <script src='https://res.wx.qq.com/open/js/jweixin-1.3.0.js ...
- Java 学习阶段性感想
阶段性感想·操千曲而后晓声 回顾 从2月17日 到 今天 4月19日,我算是暂时完成了Java入门的学习了. 从基本语法到面向对象,从常见API到字符串集合,从文件处理到多线程,我学到了很多,很多很多 ...
- 大数据实战-Spark实战技巧
1.连接mysql --driver-class-path mysql-connector-java-5.1.21.jar 在数据库中,SET GLOBAL binlog_format=mixed; ...
- Prometheus时序数据库-数据的插入
Prometheus时序数据库-数据的插入 前言 在之前的文章里,笔者详细的阐述了Prometheus时序数据库在内存和磁盘中的存储结构.有了前面的铺垫,笔者就可以在本篇文章阐述下数据的插入过程. 监 ...