进程内缓存助你提高并发能力!

简介: 进程内缓存助你提高并发能力!

前言

缓存,设计的初衷是为了减少繁重的IO操作,增加系统并发能力。不管是 CPU多级缓存page cache,还是我们业务中熟悉的 redis 缓存,本质都是将有限的热点数据存储在一个存取更快的存储介质中。

计算机本身的缓存设计就是 CPU 采取多级缓存。那对服务来说,我们是不是也可以采用这种多级缓存的方式来组织我们的缓存数据。同时 redis 的存取都会经过网络IO,那我们能不能把热点数据直接存在本进程内,由进程自己缓存一份最近最热的这批数据呢?

这就引出了我们今天探讨的:local cache,本地缓存,也叫进程缓存。

本文带你一起探讨下 go-zero 中进程缓存的设计。Let’s go!

快速入门

作为一个进程存储设计,当然是 crud 都有的:

  1. 我们先初始化 local cache
// 先初始化 local cache
cache, err = collection.NewCache(time.Minute, collection.WithLimit(10))
if err != nil {
  log.Fatal(err)
}

其中参数的含义:

  • expire:key统一的过期时间
  • CacheOption:cache设置。比如key的上限设置等
  1. 基础操作缓存
// 1. add/update 增加/修改都是该API
cache.Set("first", "first element")
// 2. get 获取key下的value
value, ok := cache.Get("first")
// 3. del 删除一个key
cache.Del("first")
  • Set(key, value) 设置缓存
  • value, ok := Get(key) 读取缓存
  • Del(key) 删除缓存
  1. 高级操作
cache.Take("first", func() (interface{}, error) {
  // 模拟逻辑写入local cache
  time.Sleep(time.Millisecond * 100)
  return "first element", nil
})

前面的  Set(key, value) 是单纯将  <key, value> 加入缓存;Take(key, setFunc) 则是在 key 对于的 value 不存在时,执行传入的 fetch 方法,将具体读取逻辑交给开发者实现,并自动将结果放到缓存里。

到这里核心使用代码基本就讲完了,其实看起来还是挺简单的。也可以到 https://github.com/tal-tech/go-zero/blob/master/core/collection/cache_test.go 去看 test 中的使用。

解决方案

首先缓存实质是一个存储有限热点数据的介质,面临以下的这些问题:

  1. 有限容量
  2. 热点数据统计
  3. 多线程存取

下面来说说这3个方面我们的设计实践。

有限容量

有限就意味着满了要淘汰,这个就涉及到淘汰策略。cache 中使用的是:LRU(最近最少使用)。

那淘汰怎么发生呢? 有几个选择:

  1. 开一个定时器,不断循环所有key,等到了预设过期时间,执行回调函数(这里是删除map中过的key)
  2. 惰性删除。访问时判断该键是否被删除。缺点是:如果未访问的话,会加重空间浪费。

cache 中采取的是第一种 主动删除。但是,主动删除中遇到最大的问题是:

不断循环,空消耗CPU资源,即使在额外的协程中这么做,也是没有必要的。

cache 中采取的是时间轮记录额外过期通知,等过期 channel 中有通知时,然后触发删除回调。

有关 时间轮 更多的设计文章:https://go-zero.dev/cn/timing-wheel.html

热点数据统计

对于缓存来说,我们需要知道这个缓存在使用额外空间和代码的情况下是否有价值,以及我们想知道需不需要进一步优化过期时间或者缓存大小,所有这些我们就很依赖统计能力了, go-zerosqlcmongoc 也同样提供了统计能力。所以我们在 cache 中也加入的缓存,为开发者提供本地缓存监控的特性,在接入 ELK 时开发者可以更直观的监测到缓存的分布情况。

而设计其实也很简单,就是:Get() 命中,就在统计 count 上加1即可

func (c *Cache) Get(key string) (interface{}, bool) {
  value, ok := c.doGet(key)
  if ok {
    // 命中hit+1
    c.stats.IncrementHit()
  } else {
    // 未命中miss+1
    c.stats.IncrementMiss()
  }
  return value, ok
}

多线程存取

当多个协程并发存取的时候,对于缓存来说,涉及的问题以下几个:

  • 写-写冲突
  • LRU 中元素的移动过程冲突
  • 并发执行写入缓存时,造成流量冲击或者无效流量

这种情况下,写冲突好解决,最简单的方法就是 加锁

// Set(key, value)
func (c *Cache) Set(key string, value interface{}) {
  // 加锁,然后将 <key, value> 作为键值对写入 cache 中的 map
  c.lock.Lock()
  _, ok := c.data[key]
  c.data[key] = value
  // lru add key
  c.lruCache.add(key)
  c.lock.Unlock()
  ...
}
// 还有一个在操作 LRU 的地方时:Get()
func (c *Cache) doGet(key string) (interface{}, bool) {
  c.lock.Lock()
  defer c.lock.Unlock()
  // 当key存在时,则调整 LRU item 中的位置,这个过程也是加锁的
  value, ok := c.data[key]
  if ok {
    c.lruCache.add(key)
  }
  return value, ok
}

而并发执行写入逻辑,这个逻辑主要是开发者自己传入的。而这个过程:

func (c *Cache) Take(key string, fetch func() (interface{}, error)) (interface{}, error) {
  // 1. 先获取 doGet() 中的值
  if val, ok := c.doGet(key); ok {
    c.stats.IncrementHit()
    return val, nil
  }
  var fresh bool
  // 2. 多协程中通过 sharedCalls 去获取,一个协程获取多个协程共享结果
  val, err := c.barrier.Do(key, func() (interface{}, error) {
    // double check,防止多次读取
    if val, ok := c.doGet(key); ok {
      return val, nil
    }
    ...
    // 重点是执行了传入的缓存设置函数
    val, err := fetch()
    ...
    c.Set(key, val)
  })
  if err != nil {
    return nil, err
  }
  ...
  return val, nil
}

sharedCalls 通过共享返回结果,节省了多次执行函数,减少了协程竞争。

总结

本篇文章讲解了本地缓存设计实践。从使用到设计思路,你也可以根据你的业务动态修改 缓存的过期策略加入你想要的统计指标,实现自己的本地缓存。

甚至可以将本地缓存和 redis 结合,给服务提供多级缓存,这个就留到我们下一篇文章:缓存在服务中的多级设计

关于 go-zero 更多的设计和实现文章,可以关注『微服务实践』公众号。

项目地址

https://github.com/tal-tech/go-zero

相关文章
|
1月前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
5月前
|
安全 Python
告别低效编程!Python线程与进程并发技术详解,让你的代码飞起来!
【7月更文挑战第9天】Python并发编程提升效率:**理解并发与并行,线程借助`threading`模块处理IO密集型任务,受限于GIL;进程用`multiprocessing`实现并行,绕过GIL限制。示例展示线程和进程创建及同步。选择合适模型,注意线程安全,利用多核,优化性能,实现高效并发编程。
78 3
|
5月前
|
Python
解锁Python并发新世界:线程与进程的并行艺术,让你的应用性能翻倍!
【7月更文挑战第9天】并发编程**是同时执行多个任务的技术,提升程序效率。Python的**threading**模块支持多线程,适合IO密集型任务,但受GIL限制。**multiprocessing**模块允许多进程并行,绕过GIL,适用于CPU密集型任务。例如,计算平方和,多线程版本使用`threading`分割工作并同步结果;多进程版本利用`multiprocessing.Pool`分块计算再合并。正确选择能优化应用性能。
41 1
|
5月前
|
缓存 安全 算法
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
58 0
|
3月前
|
网络协议 C语言
C语言 网络编程(十三)并发的TCP服务端-以进程完成功能
这段代码实现了一个基于TCP协议的多进程并发服务端和客户端程序。服务端通过创建子进程来处理多个客户端连接,解决了粘包问题,并支持不定长数据传输。客户端则循环发送数据并接收服务端回传的信息,同样处理了粘包问题。程序通过自定义的数据长度前缀确保了数据的完整性和准确性。
|
4月前
|
算法 Java
JUC(1)线程和进程、并发和并行、线程的状态、lock锁、生产者和消费者问题
该博客文章综合介绍了Java并发编程的基础知识,包括线程与进程的区别、并发与并行的概念、线程的生命周期状态、`sleep`与`wait`方法的差异、`Lock`接口及其实现类与`synchronized`关键字的对比,以及生产者和消费者问题的解决方案和使用`Condition`对象替代`synchronized`关键字的方法。
JUC(1)线程和进程、并发和并行、线程的状态、lock锁、生产者和消费者问题
|
3月前
|
C语言
C语言 网络编程(八)并发的UDP服务端 以进程完成功能
这段代码展示了如何使用多进程处理 UDP 客户端和服务端通信。客户端通过发送登录请求与服务端建立连接,并与服务端新建的子进程进行数据交换。服务端则负责接收请求,验证登录信息,并创建子进程处理客户端的具体请求。子进程会创建一个新的套接字与客户端通信,实现数据收发功能。此方案有效利用了多进程的优势,提高了系统的并发处理能力。
|
3月前
|
数据采集 消息中间件 并行计算
进程、线程与协程:并发执行的三种重要概念与应用
进程、线程与协程:并发执行的三种重要概念与应用
74 0
|
5月前
|
数据处理 调度 Python
Python并发编程实战指南:深入理解线程(threading)与进程(multiprocessing)的奥秘,打造高效并发应用!
【7月更文挑战第8天】Python并发编程探索:使用`threading`模块创建线程处理任务,虽受限于GIL,适合I/O密集型工作。而`multiprocessing`模块通过进程实现多核利用,适用于CPU密集型任务。通过实例展示了线程和进程的创建与同步,强调了根据任务类型选择合适并发模型的重要性。
64 5
|
5月前
|
数据库 数据安全/隐私保护 C++
Python并发编程实战:线程(threading)VS进程(multiprocessing),谁才是并发之王?
【7月更文挑战第10天】Python并发对比:线程轻量级,适合I/O密集型任务,但受GIL限制;进程绕过GIL,擅CPU密集型,但通信成本高。选择取决于应用场景,线程利于数据共享,进程利于多核利用。并发无“王者”,灵活运用方为上策。
70 2
下一篇
DataWorks