进阶 docs.memcached.org 2026-10-13 18:51:48 · 20 阅读

第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")

评论 (0)