在构建微服务时,理解何时以及如何使用缓存背后的细微差别是非常重要的,在这篇文章中,我将讨论关于缓存的一般概念,关于memcached的一些具体细节,我将介绍在Go中使用memcached的事实包。 github.com/bradfitz/gomemcache.
为什么需要缓存?
缓存的概念并不新鲜,特别是在微服务中,我们必须清楚地了解这个概念才能正确使用它;避免过度使用,并知道何时以及如何对过度缓存可能造成的缺陷做出相应的反应。
如果使用得当,缓存可以更快地将结果返回给我们的用户。这些结果的例子可能是数据库记录、渲染页面或任何其他昂贵的计算。
另一方面,如果缓存使用不当,可能会导致额外的延迟(如果是像memcached这样的分布式数据存储),内存耗尽(如果是本地进程内缓存),结果过期,甚至内部错误,使我们的服务失败。
这就是为什么在考虑对某些东西进行缓存之前,我们需要对以下问题有明确的答案。
- 我们能不能通过其他方式加快结果的速度?
- 我们是否确切知道如何使结果失效?
- 我们是使用分布式缓存还是进程内缓存?赞成/反对意见清楚吗?
让我们把这些问题再扩展一下。
我们能否以其他方式加快结果的速度?
这取决于我们到底要缓存什么,例如在我们谈论计算的情况下,也许我们可以提前预计算这些值,把它们保存在一个持久性存储中,然后根据需要查询这些值。
如果我们谈论的是一个复杂的算法,比方说,一个需要排序结果的调用,也许我们可以改变算法本身。
在更具体的情况下,比如建立一个返回资产的HTTP服务时,也许使用CDN(内容交付网络)更有意义。
我们是否确切知道如何使结果无效?
缓存时,我们最不想做的事情就是返回陈旧的结果,这就是为什么知道何时失效是很重要的。
在确定这一点时,通常采取的方法是使用基于时间的过期值,比方说,我们正在缓存每天上午10点计算的值,以这个时间为参考,我们可以用下一次计算发生前的剩余时间来确定过期值。
在更复杂的架构中,这可以使用事件按需完成,这些变化的生产者会发出事件,用于使当前的缓存值失效。
最终,重要的是总是有办法使这些结果失效。
我们是使用分布式缓存还是进程内缓存?优点/缺点是否清楚?
Distributed caching 当一个微服务有多个实例时,使用分布式缓存是一个很好的解决方案,这样可以参考相同的结果,但是这又给我们的服务增加了一个网络调用,可能会减慢速度,测量这些调用以及了解正在使用的键有助于我们确定在热键存在的情况下应该改变什么。
特别是在memcached中,热键可能真的会阻碍我们的微服务,这发生在使用memcached服务器集群的时候,有些键是如此受欢迎,以至于一直只被重定向到同一个实例。这就增加了网络流量,降低了整个过程的速度,解决这个问题的一些方法包括复制缓存数据或使用进程内缓存。
In-process caching 是另一种处理缓存的方法,然而,由于那些缓存值的性质,我们必须清楚地知道我们有多少内存,因此我们可以存储多少数据,有了这个解决方案,我们没有办法在不与实例直接交互的情况下使结果全面无效;但我们知道肯定不会发生额外的网络调用。
使用memcached进行缓存
下面的例子的代码可以在Github上找到。
根据官方网站(强调是我的)。
Memcached是一个内存键值存储器,用于存储来自数据库调用、API调用或页面渲染结果的小块任意数据(字符串、对象)。
memcached API是如此简单却又如此强大,有两种方法我们将在大多数时候使用。 Get和 Set;在使用这些方法之前,需要记住的重要事情是将数据转换为[]byte ,例如,假设我们有结构类型Name 。
// server.go
type Name struct {
NConst string `json:"nconst"`
Name string `json:"name"`
BirthYear string `json:"birthYear"`
DeathYear string `json:"deathYear"`
}
当使用NConst 作为缓存记录的键时,我们首先使用encoding/gob 设置转换的值。
// memcached.go
func (c *Client) SetName(n Name) error {
var b bytes.Buffer
if err := gob.NewEncoder(&b).Encode(n); err != nil {
return err
}
return c.client.Set(&memcache.Item{
Key: n.NConst,
Value: b.Bytes(),
Expiration: int32(time.Now().Add(25 * time.Second).Unix()),
})
}
然后,在获取它的时候,如果值存在,也会使用类似的过程进行转换。
// memcached.go
func (c *Client) GetName(nconst string) (Name, error) {
item, err := c.client.Get(nconst)
if err != nil {
return Name{}, err
}
b := bytes.NewReader(item.Value)
var res Name
if err := gob.NewDecoder(b).Decode(&res); err != nil {
return Name{}, err
}
return res, nil
}
在代码例子中,我们有一个假设的HTTP服务器,正在返回那些来自持久性数据库的值,它的实际使用情况是这样的。
// server.go
router.HandleFunc("/names/{id}", func(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
val, err := mc.GetName(id)
if err == nil {
renderJSON(w, &val, http.StatusOK)
return
}
name, err := db.FindByNConst(id)
if err != nil {
renderJSON(w, &Error{Message: err.Error()}, http.StatusInternalServerError)
return
}
_ = mc.SetName(name) // XXX: consider error
renderJSON(w, &name, http.StatusOK)
})
工作流程始终是一样的。
- 从memcached中获取值,如果它存在,则返回它。
- 如果它不存在,则查询原始数据存储并将其存储在memcached中。
结论
缓存是改善我们服务的用户体验的好方法,因为它允许我们更快地将结果返回给我们的客户。具体到memcached ,我们需要衡量使用情况,以确定何时扩大规模,或者也许添加额外的缓存机制,以保持理想的体验。