第22章 Memcached 用户指南:应用集成与缓存实践
用户指南
本基础教程通过伪代码展示如何开始将 memcached 集成到你的应用中。memcached 不是自动的应用加速器:它必须通过代码集成进应用。如果你对 memcached 还不熟悉,Caching Story Tutorial 是个不错的入门材料。
基础数据缓存
memcached 的“hello world”就是:从某处取“某个东西”,可能稍作处理,然后放进缓存,让它 N 秒后过期。
初始化 memcached 客户端
仔细阅读你所使用客户端的文档。
my $memclient = Cache::Memcached->new({ servers => [ '10.0.0.10:11211', '10.0.0.11:11211' ]}); # 伪代码
memcli = new Memcache
memcli:add_server('10.0.0.10:11211') 少数客户端允许你反复添加相同的服务器而不会出问题,但大多数客户端要求你在请求开始时只创建一次 memcached 客户端对象,并可能在不同请求之间复用它。反复初始化可能导致应用内存泄漏,或者不断向 memcached 堆积连接,直到出故障。
包装 SQL 查询
memcached 最主要的用途就是减轻 SQL 数据库的负载。
# 不要加载小 Bobby 的表
sql = "SELECT * FROM user WHERE user_id = ?"
key = 'SQL:' . user_id . ':' . md5sum(sql)
# 我们要检查值是否是 'defined',因为 '0' 或 'FALSE' # 可能是合法值!
if (defined result = memcli:get(key)) {
return result
} else {
handler = run_sql(sql, user_id)
# 执行 SQL 后返回的往往是一个特殊的 handler 对象,
# 这种对象不能直接缓存。只能缓存字符串、数组、
# 哈希/字典/表等结构
rows_array = handler:turn_into_an_array
# 缓存五分钟
memcli:set(key, rows_array, 5 * 60)
return rows_array
} 哇,飞快!缓存了这些用户数据后,用户最长会在五分钟内看到同样的数据。除非用户修改时你主动让缓存失效,否则最多要等五分钟才能看到变化。
很多时候这就够用了。比如你有一些复杂查询,如统计用户数或某个帖子板块的帖子数量,用一层扁平缓存来限制这些查询的执行频率,通常是可接受的。
包装多个查询
一次 memcached 请求能打包的处理越多越好。经常可以用一次 memcached 查询替代多条 SQL 查询。
sql1 = "SELECT * FROM user WHERE user_id = ?"
sql2 = "SELECT * FROM user_preferences WHERE user_id = ?"
key = 'SQL:' . user_id . ':' . md5sum(sql1 . sql2)
if (defined result = memcli:get(key)) {
return result
} else {
# 记得加上错误处理,孩子们 ;)
handler = run_sql(sql1, user_id)
t[info] = handler:turn_into_an_array
handler = run_sql(sql2, user_id)
t[pref] = handler:turn_into_an_array
# 客户端会自动把这个哈希/表/字典等
# 序列化好
memcli:set(key, t, 5 * 60)
return t
} 加载一个用户时,你需要获取用户本身以及他们的站点偏好(是否对其他用户可见、显示什么主题等)。原本需要两次查询和可能多行数据的操作,现在变成了一个缓存条目,缓存五分钟。
包装对象
有些语言允许配置对象的序列化。具体怎么做超出本文范围,但有几条通用建议:
- 想想是否真的需要序列化整个对象。你的构造函数很可能可以直接从缓存读取。
- 尽可能高效简单地序列化。在对象的构建/销毁上花太多时间会拖累 CPU。
- 再考虑一下:如果你为一次请求反序列化了一个巨大的对象,却只用了其中一小部分,那可能值得把那些部分分开缓存。
片段缓存
曾经 ESI(Edge Side Includes)风靡一时,可惜它们需要特殊的代理/缓存等设施。其实在你的应用内部,对于动态的、需要认证的页面,完全可以自己实现类似效果。
memcached 不只是用来减少数据库查询,你也可以缓存渲染好的 HTML。
# 生成一个个人简介页!
user = fetch_user_info(user_id)
bio_template = fetch_biotheme_for(user_id)
page_template = fetch_page_theme
pagedata = fetch_page_data
bio_fragment = apply_template(bio_template, user)
page = apply_template(page_template, bio_fragment)
print "Content-Type: text/html", page 在这个过度简化的例子里,我们先加载用户数据(这里可以用缓存!),加载网页“简介”部分的原始模板(这里也可以用缓存!),然后加载包含页头和页脚的主模板。
最后把所有内容一起渲染成主页面并输出。模板渲染可能开销不小。如果渲染时需要为查看者定制页头,你可以缓存组装好的简介片段;如果不需要,也可以直接缓存整个页面的输出。
key = 'FRAG-BIO:' . user_id
if (result = memcli:get(key)) {
return result
} else {
user = fetch_user_info(user_id)
bio_template = fetch_biotheme_for(user_id)
bio_fragment = apply_template(bio_template, user)
memcli:set(key, bio_fragment, 5 * 15)
return bio_fragment
} 看到了吧?没必要做的活就别做。能打包的处理越多,页面渲染越快,用户越开心。
扩展功能
除了 ‘set’,还有 add、incr、decr 等命令。它们本身简单,但要用好还需要一些技巧。
add 的正确用法
add 允许你在值不存在时才写入。适用于初始化计数器、设置锁,或写入不希望被轻易覆盖的数据。不过 add 的处理中有一些奇怪的小坑和竞态条件。
# 高地人只能有一个
key = "the_highlander"
real_highlander = memcli:get(key)
if (! real_highlander) {
# 嗯,那儿没人是。
var = fetch_highlander
if (! memcli:add(key, var, 3600)) {
# 糟了!被别人抢先了!
# 我们可以用刚取到的变量,
# 也可以再 `get` 一次,以防那是更新的数据。
real_highlander = memcli:get(key)
} else {
# 我们赢了!
gloat
}
}
return real_highlander incr 和 decr 的正确用法
incr 和 decr 命令可用于维护计数器,比如页面的访问次数、对用户的速率限制等。这些命令支持加 1 或更大的值,甚至负值。
但它们不会初始化不存在的值。
# 记录一次访问!
key = 'hits: ' . user_id
if (! memcli:incr(key, 1)) {
# 糟糕,键还不存在!
# 别人可能也刚好发现了这一点,
# 所以我们用 `add` 而不是 `set`
if (! memcli:add(key, 1, 60 * 60 * 24)) {
# 失败了!别人已经把值放回去了。
# 那就再试一次 incr。
memcli:incr(key, 1)
} else {
return success
}
} else {
return success
} 一不小心,这次访问就漏统计了 :) 你可以包装一下逻辑重试几次,或者干脆不重试,取决于你觉得它有多重要。关键是这种场景下,本想做 add 时千万别误用 set。
缓存失效
要进阶使用 memcached,你需要学会主动让缓存失效(或重新验证)。
当用户修改了自己的数据时,你应该设法让缓存保持同步,让用户完全察觉不到自己在看缓存数据。
过期
一个好的起点是调整你的过期时间。即使你在主动删除或覆写缓存数据,仍然应该让缓存定期过期,以防应用有 bug、崩溃、网络抖动或其他导致缓存与数据不同步的情况。
选择过期时间没有“经验法则”。静下心来想想你的用户和数据:多久不更新会让用户不爽?对自己诚实点,“用户永远需要最新数据”并不一定成立。
过期时间以无符号整数秒指定,范围从 0(表示“永不过期”)到 30 天(60*60*24*30)。超过 30 天的值会被解释为 unix 时间戳。如果你想的对象在明年 1 月 1 日过期,就这么做。
对于 binary protocol,过期时间必须是无符号数。ASCII protocol 如果收到负的过期时间,会把它当作“立即过期”处理。
delete
最简单的失效方式就是直接删除,让网站下次读取时重新缓存数据。
比如用户 Bob 更新了他的个人简介,你希望他刷新页面时能看到最新内容,那么你就执行:
memcli:delete('FRAG-BIO: ' . user_id) ……下次他加载页面时,就会从数据库读取并重新填充缓存。
set
最高效的方式是在数据变化时主动更新缓存。Bob 更新简介时,直接把他的简介对象通过 ‘set’ 塞进缓存。你可以把新数据传给原本负责检查数据的那个函数,或者按自己的方式组织代码。
运筹得当的话,数据库只需处理写操作和很久没被访问的数据。
按标签失效
TODO:链接到命名空间文档,并说明这种方式不可行。
键的使用
好好设计你的键能省下大量时间和内存。memcached 本质是一个哈希表,但内部也会记住完整的键。键越长,memcached 查找时需要哈希的字节就越多,存储键完整副本浪费的内存也越多。
另一方面,键应该能让你轻松判断它在代码中的来源,否则就有得debug了。
避免使用用户输入
如果直接用任意的用户输入做键,很容易破坏 memcached。ASCII protocol 会用空格和换行符,确保键里不出现这两者,方可长命百岁。binary protocol 没有这个问题。
短键
64 位 UID 是标识用户的聪明办法,但打印出来很难看:18446744073709551616,整整 20 个字符!用 base64 编码,甚至只是十六进制,都能缩短不少。
binary protocol 下可以存储任意内容,所以你可以直接把 4 个字节打包进键。不过这样就没法通过 ASCII protocol 读回,而且你应该准备一些工具来弄清键的含义。
可读性强的键
key = 'SQL' . md5sum("SELECT blah blah blah") ……可能很聪明,但当你通过 tcpdump、strace 等工具查看这个键时,完全不知道它从哪来的。
在这个例子中,你可以把 SQL 查询放在一个外部文件里,旁边标注对应的 md5sum。或者更简单:在键里附加一个唯一的查询 ID。
key = 'SQL' . query_id . ':' . m5sum("SELECT blah blah blah")