话说一个美滋滋的上午, 突然就出现大量报警, 接口大量请求都响应超时了.

排查过程

  1. 查看服务器的监控系统, CPU, 内存, 负载等指标正常
  2. 排查日志, 日志能够响应的结果也正常. request.log 中响应时长高达数秒
  3. 查看数据库, codis 监控, 各项指标正常

不得已, 只能打开线上 pprof 查看 Go 相关参数是否正常. 果真一下子就找到问题发生的原因

这是当时线上 pprof 的截图, 发现 40 多万 goroutine 都阻塞在 go-cache 的 Set 函数上. 更准确的说 40 多万 goroutine 在发生很严重的锁竞争. 这就让人觉得很意外了.

幸好当时在压测接口的时候, 为了避免 go-cache 的影响结果的影响, 引入了一个配置项来控制是否开启 go-cache, 于是立马线上关闭 go-cache, 接口响应恢复正常.

问题来了

虽说问题解决了, 但是是由于什么原因造成的呢?

  1. 为什么 go-cache 会发生这么严重的锁竞争 ?
  2. 是由于 go-cache 有代码 bug 吗 ?
  3. 如何才能稳定复现呢 ?

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 不会过期

实现过程:

  1. RWMutex.Lock
  2. 设置过期时间, 将 value 放入 map 中
  3. 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
}
  1. RWMutex.RLock
  2. 判断是否存在
  3. 判断是否过期
  4. 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
}
  1. RWMutex.Lock
  2. 判断某个 key 在 map 中是否存在
  3. 判断是否这个 key 是否过期
  4. 对这个值加 n
  5. 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 设置), 就会启动清理工作.

清理时原理:

  1. RWMutex.Lock()
  2. 遍历整个map, 检查 map 中的 value 是否过期
  3. RWMutex.Unlock()

同时, 还利用了 runtime.SetFinalizer 在 go-cache 生命周期结束时, 主动完成对过期清理协程的终止

源码分析总结

遍览整个 go-cache 源码, 会发现 go-cache 完全靠着 RWMutex 保证数据的正确性.

考虑下面的问题:

  1. 当 go-cache.New() 时设置的定时清理的时间过长, 同时 Set 的 key 的过期时间比较长, 这样会不会导致 go-cache.map 中的元素过多?
  2. 会不会当清理启动时, 锁定了 go-cache.map (注意这个时候是写锁), 由于 go-cache.map 中元素过多, 导致 map 一直被锁定, 那么这个时候所有的 Set 函数是不是就会产生 Lock 竞争?
  3. 使用 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 有点高

  1. 当时考虑到用户购买状态(这个状态可能随时变化)如果能够在本地缓存中缓存 10s, 那么用户再次点进来的时候能从本地取了, 就造成大量的数据都写入了 map 中
  2. 由于接口 QPS 比较高, 设置用户购买状态时就可能造成竞争, 造成接口响应超时

go-cache 使用注意点

  1. 尽量存放那些相对不怎么变化的数据, 适用于所有的 local cache(包括 map, sync.map)
  2. go-cache 的过期检查时间要设置相对较小, 也不能过小
  3. 那些高 QPS 的接口尽量不要去直接 Set 数据, 如果必须 Set 可以采用异步操作
  4. 监控 go-cache 里面 key 的数量, 如果过多时, 需要及时调整参数

资料

  1. go-cache
  2. bigcache
  3. freecache
  4. runtime.SetFinalizer

一次错误使用 go-cache 导致出现的线上问题的更多相关文章

  1. 改进动态设置query cache导致额外锁开销的问题分析及解决方法-mysql 5.5 以上版本

    改进动态设置query cache导致额外锁开销的问题分析及解决方法 关键字:dynamic switch for query cache,  lock overhead for query cach ...

  2. dubbo多网卡时,服务提供者的错误IP注册到注册中心导致消费端连接不上

    使用了虚拟机之后,启动了dubbo服务提供者应用,又连了正式环境的注册中心: 一旦dubbo获取的ip错误后, 这种情况即使提供者服务停掉,目前dubbo没有能力清除这类错误的提供者: (需要修改源码 ...

  3. CAS (15) — CAS 线上环境 Ehcache Replication 的非稳定重现错误 java.util.ConcurrentModificationException

    CAS (15) - CAS 线上环境 Ehcache Replication 的非稳定重现错误 摘要 线上环境在 EhCache Replication 过程中出现 java.util.Concur ...

  4. Try-Catch包裹的代码异常后,竟然导致了产线事务回滚!

    导读:​一段被try-catch包裹后的代码在产线稳定运行了200天后忽然发生了异常,而这个异常竟然导致了产线事务回滚.这期间究竟发生了什么?日常在项目过程中该如何避免事务异常?就在这个时候,老板拿着 ...

  5. HTTP 错误 404.2 - Not Found 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面

    详细错误:HTTP 错误 404.2 - Not Found. 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面. 出现环境:win7 + IIS7.0 解决办法 ...

  6. 关于wcf异常异常信息:由于内部错误,服务器无法处理该请求。有关该错误的详细信息,请打开服务器上的 IncludeExceptionDetailInFaults (从 ServiceBehaviorAttribut

    异常信息:由于内部错误,服务器无法处理该请求.有关该错误的详细信息,请打开服务器上的 IncludeExceptionDetailInFaults (从 ServiceBehaviorAttribut ...

  7. 线上故障排查——drools规则引擎使用不当导致oom

    事件回溯 1.7月26日上午11:34,告警邮件提示:tomcat内存使用率连续多次超过90%: 2.开发人员介入排查问题,11:40定位到存在oom问题,申请运维拉取线上tomcat 内存快照dum ...

  8. (转)HTTP 错误 404.2 - Not Found 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面

    详细错误:HTTP 错误 404.2 - Not Found. 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面. 出现环境:win7 + IIS7.0 解决办法 ...

  9. 解决:HTTP 错误 404.2 - Not Found. 由于 Web 服务器上的“ISAPI 和 CGI 限制”列表设置,无法提供您请求的页面

    错误重现: 在发布网站的过程中,虽然不是第一次发布了,但是还是遇到了很多的问题.为了以后可以轻松解决此类问题还是积累下来比较好. 问题:HTTP 错误 404.2 - Not Found. 由于 We ...

随机推荐

  1. 学习js、jquery、vue实现部分组件

    通过js实现radio小组件,最终效果如下 html代码: <!DOCTYPE html> <html lang="en"> <head> &l ...

  2. Vue学习笔记-基于CDN引入方式简单前后端分离项目学习(Vue+Element+Axios)

    一  使用环境 开发系统: windows 后端IDE: PyCharm 前端IDE: VSCode 数据库: msyql,navicat 编程语言: python3.7  (Windows x86- ...

  3. Centos7安装Docker&镜像加速

    目录 Docker Docker安装 方式一 方式二 docker 镜像加速 Docker Docker安装 Docker安装 方式一 step1: 删除老版本(Uninstall old versi ...

  4. Linux文本三剑客总结

    Linux文本处理三剑客 grep 文本过滤(模式:pattern)工具 grep, egrep, fgrep(不支持正则表达式搜索) grep  grep: Global search REgula ...

  5. APP跳转小程序,小程序跳转APP

    关注公共号,搜索 "APP跳转小程序,小程序跳转APP",查看原文 前置条件: 开发环境:windows 开发框架:uni-app , H5+,nativeJS,mpvue 编辑器 ...

  6. 原始提货单OBL

    转: 原始提货单OBL 什么是原始提货单OBL? 原始提货单Original Bill of Lading,简称OBL.是货运单据或运输合同,可作为货物标题和装运收据.该文件确认承运人已收到货物.签发 ...

  7. (报错解决)Exception encountered during context initialization

    转: (报错解决)Exception encountered during context initialization 关键词 JavaEE JavaWeb eclipse XML AspectJ ...

  8. POJ-2406(KMP+字符串压缩)

    Power String POJ-2406 字符串压缩模板题,但是是求有多少个这样最短的子串可以组成s. #include<iostream> #include<cstring> ...

  9. pytorch(08)数据模型的读取(2)

    import numpy as np import torch import os import random from PIL import Image from torch.utils.data ...

  10. FreeBSD 13 显卡支持

    On FreeBSD 13, using drm-devel-kmod, support is the same as on Linux 5.4. This includes support for ...