第23章 Memcached 用例实践
用户指南 用例 用例 命名空间 Memcached 不支持原生的命名空间或标签功能。由于无法在不引入大量复杂性的情况下原子性地跨所有服务器过期命名空间,原生支持很难实现。 不过,你可以轻松模拟这种功能。
通过键前缀模拟命名空间 利用协调一致的键前缀,你可以创建一个跨越整个 memcached 集群的虚拟命名空间。该前缀可以存储在配置文件中并手动更改,也可以存储在一个外部键中。
按命名空间删除 假设你有一个用户及其所有相关的键,你希望有一个一站式开关,能同时使该用户所有的缓存条目失效。 使用命名空间时,你可以设置一个包含版本号的三级键。这会带来额外的往返,用于确定命名空间。 user_prefix = memcli:get('user_namespace:' . user_id) bio_data = memcli:get(user_prefix . user_id . 'bio') 使命名空间失效只需编辑该键即可。你的应用将不再请求旧的键,这些键最终会从 LRU 尾部脱落并被回收。
在实现前缀时要小心。你需要使用 add 操作,以免覆盖现有的命名空间。你还应该将其初始化为一个再次出现的概率极低的东西。 一个简单的建议是使用 unix 时间戳。 # 命名空间管理,基本获取。 key = 'namespace:' . user_id namespace = memcli:get(key) if (!namespace) { namespace = time() if (! memcli:add(key, namespace)) { # 竞争失败。 namespace = memcli:get(key) # 可以重新测试并跳回循环开始,硬失败等。 } # 返回新的命名空间。 return namespace } 而在失效时: key = 'namespace:' . user_id if (! memcli:incr(key, 1)) { # 递增失败!键必须不存在。 memcli:add(key, time()) } 这也不是一个完美的算法,但很简单。键通过时间戳初始化,然后在每次数据需要失效时递增 1。如果失效操作不频繁,这种方法效果很好,因为缺失的键总是会被替换为一个比之前缓慢递增的数值更大的数字。 你可以通过使用毫秒精度而不是秒来进一步降低竞争条件的风险,但这会使你的键前缀变长。作为额外加分项,可以在将数字放在其他键之前对其进行 base64 编码。
存储集合或列表 将数据列表存储到 memcached 中,可以意味着存储一个包含序列化数组的单个项,或者尝试通过添加、移除项目来操作一个巨大的“集合”,而不必操作整个集合。这两种情况都应支持。 需要注意的是 memcached 对项大小的 1 megabyte 限制,因此将集合(ID、数据)整体存储到 memcached 中可能不是最好的主意。 Steven Grimm 在邮件列表中解释了一种更好的方法:http://lists.danga.com/pipermail/memcached/2007-July/004578.html Chris Hondl 和 Paul Stacey 详细介绍了实现相同目标的替代方法:http://lists.danga.com/pipermail/memcached/2007-July/004581.html 结合两者可以创建非常可扩展的列表。范围内的 ID 存储在单独的键中,数据则通过单独的键分散存储。
使用 append/prepend 管理列表 假设你选择将 ID(数字)列表存储在单个 memcached 键中,并单独获取集合项的完整数据,你可以使用 append/prepend 进行原子更新。 让我们以一个 AJAX 风格的功能为例,管理用户的兴趣列表。你给用户一个输入框。他们输入“hammers”,你的花哨 AJAX 脚本将“hammers”更新到他们的兴趣列表中,任何查看其配置文件的人都能立即看到“hammers”被添加到他们的兴趣列表中。通常,你必须尝试 CAS 更新:获取旧的缓存项,添加 hammer 兴趣 ID,然后重新设置值,或者简单地删除缓存并在下次查看时从数据库拉取整个列表。 相反,你可以使用 append。在将兴趣 ID 添加到用户的兴趣缓存条目时,只需发出一个 append 命令,附带表示该 ID 的二进制打包字符串。这与 incr/decr 类似,因为如果缓存列表尚不存在,append 将会失败。 因此,如果 append 失败,从数据库加载并运行 add;如果 add 失败,决定你有多关心该项目是否成功存入,然后做更多工作。 现在假设该用户添加了几百个兴趣,然后回到开头,决定他不再喜欢“hammers”,因为他实际上更喜欢钉子。你该如何处理?你仍然有旧的选项:尝试拉取列表、编辑并 CAS 回去,或者删除整个东西。或者你可以维护一个黑名单。 在初始化列表时,列表中的第一个字节可以是“零标记”,一个完整的 4 或 8 字节(取决于你的兴趣 ID 大小)的值,其中只包含零。 当你从 memcached 加载列表时,你读取的第一组项目将是“黑名单”项目。一旦你遇到值为“0”的情况,开始将列表读取为应该存在的项目。你可以将每个项目与“黑名单”进行检查,不将其纳入显示列表。 因此,对于列表不会变得太大的常见情况,你可以将添加的项目放在末尾,然后将移除的项目前置到开头。如果缓存被清除并重新加载,它本来就不会包含被黑名单排除的项目,因此缓存条目会被清理。 这基于缓存大小有明显的局限性,但这是避免与挑剔用户相关的极其昂贵的重新缓存操作的一种巧妙方式。
零字节值 不要这样做: if (data = memcli:get('helloworld')) { # 耶,有事! } ……因为你可能有完全有效的数据,其结果为 0、false 或空。空键对于建议锁、缓存状态标志等很有用。如果你能仅从键的存在中获得所需的一切,就不需要浪费字节来存储额外数据。
减小键的大小 键越小,内存开销越小。在较低的 slab 类中,较小的项可能更重要,因为节省几个字节可能使项进入更高效的 slab 类。此外,键限制为 250 个字符(实际上是这样,未来可能会提高到 65k)。 当容易或有意义时,压缩键。“super_long_function_names_abstract_key”可能具有描述性,但是一种浪费。将其简化为你可以在代码中 grep 的函数 ID,比如“slfnak”或类似的。 对长数字进行 Base64 编码。使用命令行程序将其转回数字很容易。 二进制协议允许设置任意键。你可以使用字节打包将它们压缩到原生大小,而不是 base64 编码。同样,你可以使用两个字节标识符代替‘sflnak’,并将数字映射回你的代码。 然而,除非你真的缺乏额外内存,否则不要做这些。像这样的简单更改有时可以节省 5 到 20% 的内存,但通常购买几 GB 的 RAM 比你的时间更便宜。
安全地加速计数器 TODO:这是一个 memcached/mysql 混合体,用于避免不断运行 count(*) from table where user_id = ?。我稍后会详细阐述这一点,因为我在这里发现了一些 bug(死锁?)。
速率限制 TODO:关于这个主题有一些不错的帖子。我看到过一些有 bug 的幻灯片。需要整理一下。
松散的中央锁 虽然我们不建议在任何严肃的锁定情况下这样做,但有时你可以从通过 memcached 获得的建议性、有时可靠的“锁”中受益。 鉴于一个热门且难以重建的缓存项,你可能会遇到数十(或数百)个进程同时攻击你的数据库以尝试填充缓存的情况。下面将更详细地讨论作为“羊群踩踏”问题的情况,我们将描述一种使用 add 创建建议性“松散锁”的简单方法: key = "expensive_frontpage_item" item = memcli:get(key) if (! defined item) { # 糟糕,我们必须重新缓存它! # 给我们 60 秒重新缓存该项目的机会。 if (memcli:add(key . "_lock", 60)) { item = fetch_expensive_thing_from_database memcli:add(key, item, 86400) memcli:delete(key . "_lock") } else { # 竞争失败。我们可以做任何数量的事情: # - 短暂睡眠,然后重新获取。 # - 尝试上述操作几次,然后缓慢获取并返回该项目 # - 向用户显示没有此昂贵内容的页面 # - 显示一些不太昂贵的内容 # - 抛出错误 } } return item 最坏的情况下,你可能会在没有锁的情况下操作。最好的情况下,你不需要增加基础设施就可以减少正在进行的并行查询数量。 使用此方法需自担风险!‘add’ 可能因为键已存在或远程服务器宕机而失败。如果你的客户端没有提供区分这两种情况的方法,你必须决定在进行查询或抛出错误之前要多努力地尝试。
避免羊群踩踏 当热门(或昂贵)项目的缓存未命中导致大量应用进程攻击你的数据库以获取答案时,这是一个大问题。避免这个问题有很多选择,我们将在下面讨论几种。
松散锁 如上所示,在紧急情况下,你可以使用 memcached 的 add 功能来降低需要运行查询的概率。
外部互斥锁 第三方集中互斥锁也可以使用。MySQL 有 SELECT GET_LOCK() ... RELEASE_LOCK(),速度很快,但需要稍微麻烦一下你的数据库。也存在其他服务,但这位作者对自己所知道的了解不够自信,无法推荐任何特定的 ;)
扩展过期时间 一种常见的技巧是在缓存对象中使用嵌入的软过期值。如果你的对象预计一小时后过期,实际将其设置为 1.5 小时或更多过期。在你的对象内部设置一个你认为对象变旧时的“软超时”。 当你获取的对象超过软超时时,你可以选择任何与你一致的方法来重新缓存它: 执行上面提到的“锁”。如果你未能获取锁,返回旧的缓存项。锁的获得者重新缓存。 也存储一个“硬”超时,或者假设硬超时是软超时加一个值。随机决定你是否想重新缓存,并随着项目变旧增加重新缓存该值的概率。 分发异步作业重新缓存对象。 等等。 缓存对象仍然可能因多种原因消失(服务器重启、LRU、驱逐等)。将其作为缓解措施,而不是你唯一的防线。
Gearmand 或类似工具 使用作业服务器可以很容易地获胜。Gearman 是一个常见的、快速的、可扩展的作业服务。虽然将重新缓存请求通过作业服务器转发肯定会增加开销,但你可以有选择地使用该服务,或者完全依赖后台作业。也许你想将高流量用户通过 gearmand 转发,但不要针对其他任何人。 Gearmand 有两个神奇的技巧;异步或同步作业处理。 在扩展过期值的情况下,你可以发出一个异步作业来重新缓存对象,然后将缓存返回给一个用户。Gearmand 可以将类似的作业合并,以免最终执行数百万个。 在同步更新的情况下,gearmand 可以将具有相同参数的传入作业合并。因此,发出作业请求的第一个进程将获取一个工作器来重新缓存数据。在他之后的所有其他进程将“订阅”该第一个作业的结果,而不创造更多的并行性。当第一个作业完成时,gearmand 将响应广播给所有监听器,他们都会像直接发出请求一样继续前进。 非常方便。
松散复制 一些客户端可能原生支持复制。它会在两个唯一的服务器上存储值,然后线性或随机再次获取该值。有时你没有这个功能! 你可以通过修改键并将值存储两次来“尝试”但不保证复制。如果你想从三个位置存储一个高度检索的值,你可以在键的末尾添加‘1’、‘2’或‘3’,并将它们全部存储。然后在获取时随机选择一个。 这有很多陷阱;存储失败意味着你更有可能拿到过时的数据。但在紧急情况下是个可爱的技巧。
使用 add“触摸”键 创建一个你想在一周后过期的项?并不总是获取该键但希望它保留在 LRU 顶部附近?add 实际上会提升一个值到 memcached 的 LRU 前面,如果它已经存在。如果 add 调用成功,这意味着无论如何都是时候重新缓存该值了。
FAQ