Zulip 缓存体系深度解析:memcached、Django 集成与失效机制实战指南
Zulip 缓存体系深度解析memcached、Django 集成与失效机制实战指南【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulipZulip 是一款开源团队聊天软件其服务器端大量依赖缓存来支撑高性能与可扩展性。本文以 Zulip 官方子系统文档 docs/subsystems/caching.md 为核心骨架结合仓库源码 zerver/lib/cache.py 与各模型模块系统讲解 Zulip 的缓存架构后端 memcached 的使用方式、cache_with_key装饰器与缓存键设计、基于 Djangopost_save信号的缓存失效机制、生产环境部署中的KEY_PREFIX隔离策略、测试与开发环境的缓存处理以及进程内缓存与浏览器端缓存状态同步。读完本文你将掌握在 Zulip 代码库中如何正确读写缓存、如何避免经典的缓存一致性 Bug并理解其缓存设计背后的工程取舍。一、缓存策略总览为什么 Zulip 如此依赖缓存与任何追求高性能的产品一样Zulip 在多个层面广泛使用缓存官方文档开篇即点明本文聚焦后端memcached的使用因为这是人们谈论服务器如何做缓存时最先想到的部分。Zulip 之所以在内存缓存上投入巨大核心原因是数据库查询与缓存读取之间存在数量级的性能差距在 Django 中一个典型的普通数据库查询耗时通常是一个 memcached 取操作fetch的 3~10 倍。因此在用户请求的热路径上尽量命中缓存、减少数据库往返是 Zulip 高性能与高并发可扩展性的基石。后端缓存的总体结构分为两层Django 内置的缓存框架负责与 memcached 服务器的底层通信django.core.cache见 zproject/computed_settings.py 中的CACHES配置应用层封装库zerver/lib/cache.py提供 Zulip 特有的缓存键构造、批量读写、前缀管理、失效工具等能力是本文主角。一个常见的不良工程实践是项目里到处散落着显式读写缓存的代码或更糟散落着清空 memcached 后 Bug 就消失的诡异问题。Zulip 的设计目标截然相反只要把少量核心缓存代码写对绝大多数开发者自然而然写出的业务代码就能既自动受益于缓存、又不产生任何缓存一致性问题。由此带来一个显著的使用体验在 Zulip 的 Django 代码库中很多地方只需要调用标准的访问器函数——例如用get_user获取用户对象或在视图代码中调用access_stream_by_id这类带权限校验的函数——数据获取就会自动命中 memcached 缓存因为访问器已经内部接入了缓存系统同时开发者完全无需担心拿到的数据是否过期因为写入端的失效逻辑已由框架保证。顺带一提即使不考虑缓存坚持使用这些访问器函数本身也是良好的工程实践因为它们还统一处理了许多容易忽略的细节例如频道名、邮箱地址的大小写不敏感匹配。这类在多个地方重复实现的微妙逻辑几乎注定会在某处出 Bug而 Zulip 中这些访问器被调用了成百上千次缓存的加持只是锦上添花。二、核心实现cache_with_key装饰器与经典缓存范式2.1 一个典型的例子get_usercache_with_key是 Zulip 应用层缓存库的基石装饰器。文档以get_user为例说明在其之上只需要极少量代码def user_profile_by_email_realm_id_cache_key(email: str, realm_id: int) - str: return fuser_profile:{hashlib.sha1(email.strip().encode()).hexdigest()}:{realm_id} def user_profile_by_email_realm_cache_key(email: str, realm: Realm) - str: return user_profile_by_email_realm_id_cache_key(email, realm.id) cache_with_key(user_profile_by_email_realm_cache_key, timeout3600 * 24 * 7) def get_user(email: str, realm: Realm) - UserProfile: # 为简洁起见此处省略了少量预取关联对象的复杂度 return UserProfile.objects.get(email__iexactemail.strip(), realmrealm)这段代码实现了经典的读穿read-through缓存范式其中包含三个关键设计点1. 缓存键函数参数到字符串的唯一映射。user_profile_by_email_realm_id_cache_key把参数的规范形式映射为唯一字符串。缓存键是带命名空间的user_profile:前缀从而不会与其他缓存互相重叠同时键内编码了参数此处是邮箱地址的 SHA-1 哈希 realm ID保证同一缓存的两次使用也不会重叠。文档特别提醒必须对邮箱做哈希make_safe_digest/hashlib.sha1避免把特殊字符直接发送给 memcached。Zulip 提供了两个版本的键函数分别适配调用方只持有Realm对象或仅有realm_id的情形。2. 读穿语义。调用get_user时cache_with_key先计算键然后向 memcached 发起一次cache_get查询若命中则直接返回值若未命中则执行函数体从数据库取数写回该 memcached 键后再返回给调用方。核心逻辑见 zerver/lib/cache.pydef cache_with_key( keyfunc: Callable[ParamT, str], cache_name: str | None None, timeout: int | None None, pickled_tupled: bool True, ) - Callable[[Callable[ParamT, ReturnT]], Callable[ParamT, ReturnT]]: Decorator which applies Django caching to a function. Decorator argument is a function which computes a cache key from the original functions arguments. You are responsible for avoiding collisions with other uses of this decorator or other uses of caching. def decorator(func: Callable[ParamT, ReturnT]) - Callable[ParamT, ReturnT]: wraps(func) def func_with_caching(*args: ParamT.args, **kwargs: ParamT.kwargs) - ReturnT: key keyfunc(*args, **kwargs) try: val cache_get(key, cache_namecache_name) except InvalidCacheKeyError: stack_trace traceback.format_exc() log_invalid_cache_keys(stack_trace, [key]) return func(*args, **kwargs) # Values are singleton tuples so that we can distinguish a # result of None from a missing key. Setting # pickled_tupledFalse avoids pickling the result (if its # a raw string or bytes) at the cost of losing this # distinction. if val is not None and pickled_tupled: return val[0] val func(*args, **kwargs) if isinstance(val, QuerySet): logging.error( cache_with_key attempted to store a full QuerySet object -- declining to cache, stack_infoTrue, ) else: cache_set( key, val, cache_namecache_name, timeouttimeout, pickled_tupledpickled_tupled ) return val return func_with_caching return decorator值得注意的底层细节cache_with_key默认以单元素元组pickled_tupledTrue存放值从而可以区分缓存结果为None与缓存键不存在两种情形若确认缓存值是原始字符串或字节、且不需要这种区分可传入pickled_tupledFalse省去 pickle 开销例如 zerver/lib/message_cache.py 中缓存消息 dict 时即如此。此外装饰器会拒绝缓存QuerySet对象记录 error 日志而非静默缓存避免把惰性求值的查询集错误地序列化进缓存。3. 过期时间。缓存项在timeout后过期此例为一周3600 * 24 * 7。不过在 chat.zulip.org 这类频繁部署的环境中缓存项通常远未到期就停止被使用了——因为每次生产部署都会轮换KEY_PREFIX详见第五节旧前缀下的缓存键自然无人问津。该装饰器在 Zulip 中被用于约 30 处节省了大量高度相似的缓存样板代码。实际使用点遍布各模块例如zerver/models/users.pyget_user_profile_by_id、get_user_profile_narrow_by_id等zerver/models/clients.py、zerver/models/groups.py、zerver/models/realms.pyzerver/lib/display_recipient.py、zerver/lib/alert_words.py、zerver/lib/realm_description.py以及 zerver/views/video_calls.py 中的 Zoom 访问令牌缓存timeout3600 - 240即令牌刷新前 4 分钟过期等。2.2 使用cache_with_key的注意事项使用cache_with_key最需要警惕的一点是一旦缓存命中函数体如上面的get_user就根本不会被调用。这意味着某些看似聪明的代码复用实际上是非常糟糕的主意。官方文档给出了一个经典反例不要新增一个get_active_user函数让它复用get_user的缓存键函数却执行不同的查询例如过滤掉已停用用户。如果先调用get_active_user访问某个已停用用户行为是对的但如果先通过get_user访问了该用户那么之后get_active_user会直接从缓存返回该用户永远不会执行你那条更严格的查询。因此结论非常明确即使数据来自同一批对象只要数据集合的语义不同就必须使用不同的缓存键函数。2.3 缓存键的合法性校验memcached 对键长度有硬性限制MEMCACHED_MAX_KEY_LENGTH 250见 zerver/lib/cache.py且键内字符也有限制。Zulip 的validate_cache_key通过正则([!-~])限制键只能包含 ASCII 表中从!到~的友好字符并检查总长度超限或非法字符都会抛出InvalidCacheKeyErrorzerver/lib/cache.py。cache_get/cache_set在读写前都会先拼上KEY_PREFIX再做校验而safe_cache_get_many/safe_cache_set_many则提供丢弃坏键、正常处理好键的容错变体。相关测试见 zerver/tests/test_cache.py覆盖非法字符键、过长键与正常键三种场景。三、写入后的缓存失效Djangopost_save信号机制上述缓存范式对不可变immutable数据工作得很好但对于可变状态必须在数据库写入后采取措施避免 Python 进程从缓存中读到陈旧数据。Zulip 的解决方案是 Django 长久以来的post_save信号特性Django 信号允许你配置某段代码在 Django 执行特定操作后运行对post_save而言就是在任何通过 Django 的.save()写入数据库之后触发。在 zerver/models/*.py 的模型文件中有若干行代码完成信号注册例如post_save.connect(flush_realm, senderRealm) post_save.connect(flush_user_profile, senderUserProfile)实际分布如下均可在对应模型文件末尾找到zerver/models/users.pyflush_user_profilesenderUserProfilezerver/models/realms.pyflush_realmsenderRealmzerver/models/streams.pyflush_streamsenderStreamzerver/models/messages.pyflush_messagesenderMessage、flush_submessagesenderSubMessage、flush_used_upload_space_cachesenderAttachmentzerver/models/alert_words.py、zerver/models/linkifiers.py、zerver/models/muted_users.py、zerver/models/realm_emoji.py 等。一旦post_save钩子注册完毕只要项目代码中调用user_profile.save(...)Django 就会自动调用flush_user_profile。Zulip 在修改user_profile对象时系统性地使用标准 Django.save()并一致地传入update_fields参数该参数精确编码了对象上哪些字段发生了变化。这意味着只需要把这些缓存冲刷函数写对编写业务代码的人完全不需要甚至不需要知道缓存的存在。每个冲刷函数的基本算法是根据被修改的对象与update_fields信息计算出可能包含被修改数据的全部缓存键列表然后向 memcached 发送一个批量删除请求把这些键如果存在从缓存中移除。以 zerver/lib/cache.py 中的bulk_flush_users/flush_user_profile为例def bulk_flush_users( *, user_profiles: list[UserProfile], realm: Realm, update_fields: Sequence[str] | None None, ) - None: delete_user_profile_caches(user_profiles, realm.id) cache_keys_to_delete set() if changed(update_fields, realm_user_dict_fields): cache_keys_to_delete.add(realm_user_dicts_cache_key(realm.id)) if changed(update_fields, [is_active]): cache_keys_to_delete.add(active_user_ids_cache_key(realm.id)) cache_keys_to_delete.add(active_non_guest_user_ids_cache_key(realm.id)) cache_keys_to_delete.add(active_guest_user_ids_cache_key(realm.id)) if changed(update_fields, [role]): cache_keys_to_delete.add(active_non_guest_user_ids_cache_key(realm.id)) cache_keys_to_delete.add(active_guest_user_ids_cache_key(realm.id)) # 若任一 bot 修改了 dict 中的字段或非激活状态则使 bots_in_realm 失效 if changed(update_fields, bot_dict_fields): for user_profile in user_profiles: if user_profile.is_bot: cache_keys_to_delete.add(bot_dicts_in_realm_cache_key(realm.id)) cache_delete_many(list(cache_keys_to_delete)) if changed(update_fields, [email, full_name, id, is_mirror_dummy]): delete_display_recipient_cache(user_profiles)这里changed(update_fields, fields)的逻辑是若update_fields为None表示新增/删除对象无法精确定位变更字段则一律判定为需要失效否则只要任一关注字段在update_fields集合中即判定需要失效zerver/lib/cache.py。维护这些冲刷函数需要细心每新增一个缓存都要逐一检查它们但整体算法非常简单只要变更的数据以任何形式出现在某个缓存键对应的数据中该缓存键就必须被清除。例如某个 realm 的active_user_ids_cache_key缓存只是一个用户 ID 列表并不显式包含is_active标志但当该 realm 新建用户、或用户被停用/重新启用时此缓存也必须被刷新。理解这套机制后判断某个冲刷函数何时应清除某个缓存就变得很容易推理真正需要小心的是在改变缓存语义时记得做这番推理。这套缓存体系带来的整体收益是Zulip 中几乎所有的业务代码只需要修改 Django 模型对象并调用.save()缓存系统就会自动做正确的事。四、批量读穿bulk_cached_fetch与generate_bulk_cached_fetch使用 memcached 时务必避免在循环中逐条发起缓存查询数据库查询同理而应改用批量查询。Zulip 为此提供了generic_bulk_cached_fetch文档中称为generate_bulk_cached_fetch这一魔法函数核心实现在 zerver/lib/cache.pydef generic_bulk_cached_fetch( cache_key_function: Callable[[ObjKT], str], query_function: Callable[[list[ObjKT]], Iterable[ItemT]], object_ids: Sequence[ObjKT], *, extractor: Callable[[CompressedItemT], CacheItemT], setter: Callable[[CacheItemT], CompressedItemT], id_fetcher: Callable[[ItemT], ObjKT], cache_transformer: Callable[[ItemT], CacheItemT], pickled_tupled: bool True, ) - dict[ObjKT, CacheItemT]: if len(object_ids) 0: # Nothing to fetch. return {} if pickled_tupled: return generic_bulk_cached_fetch( cache_key_function, query_function, object_ids, extractorlambda val: extractor(val[0]), setterlambda val: (setter(val),), id_fetcherid_fetcher, cache_transformercache_transformer, pickled_tupledFalse, ) cache_keys: dict[ObjKT, str] {} for object_id in object_ids: cache_keys[object_id] cache_key_function(object_id) cached_objects_compressed: dict[str, CompressedItemT] safe_cache_get_many( [cache_keys[object_id] for object_id in object_ids], ) cached_objects {key: extractor(val) for key, val in cached_objects_compressed.items()} needed_ids [ object_id for object_id in object_ids if cache_keys[object_id] not in cached_objects ] # Only call query_function if there are some ids to fetch from the database: if len(needed_ids) 0: db_objects query_function(needed_ids) else: db_objects [] items_for_remote_cache: dict[str, CompressedItemT] {} for obj in db_objects: key cache_keys[id_fetcher(obj)] item cache_transformer(obj) items_for_remote_cache[key] setter(item) cached_objects[key] item if len(items_for_remote_cache) 0: safe_cache_set_many(items_for_remote_cache) return { object_id: cached_objects[cache_keys[object_id]] for object_id in object_ids if cache_keys[object_id] in cached_objects }其工作流程是①为每个 object_id 计算缓存键②用safe_cache_get_many一次批量取缓存③仅对未命中的 ID 调用一次query_function访问数据库④把缺失数据经cache_transformer/setter转换后批量写回缓存⑤返回object_id → 数据的映射。它支持若干花哨特性参数职责如下object_ids要查找的对象 ID 列表cache_key_functionobject_id → 缓存键query_function[object_ids] → [数据库对象]id_fetcher数据库对象 → object_id当使用的键比obj.id更复杂时cache_transformer数据库对象 → 缓存值当缓存的是对象的某种派生结果而非对象本身时setter/extractor写入缓存前/读回缓存后对值的变换例如对message对象做压缩以最小化 Django 与 memcached 之间的数据传输量。其便捷封装bulk_cached_fetch在 ID 即键、无需转换时直接可用。Zulip 中使用该函数的典型位置包括zerver/lib/display_recipient.py获取多个收件人展示信息与 zerver/lib/display_recipient.py批量取流展示信息zerver/lib/message.py批量构造消息 dict测试用例见 zerver/tests/test_cache.py。五、生产部署与数据库迁移KEY_PREFIX前缀轮换升级 Zulip 服务器时一个关键隐患是不同版本代码之间互相污染缓存旧版代码写入的对象布局可能与新版不一致新版若直接读到旧缓存就会出问题。Zulip 通过巧妙的缓存策略避免这一点每个生产环境的部署目录deployment directory内部都含有一个var/remote_cache_prefix文件其中保存着一个缓存前缀代码中的KEY_PREFIX它会被自动追加到该部署目录访问的任何缓存键的开头这一切由 zerver/lib/cache.py 内部处理。KEY_PREFIX的生成与读取逻辑见 zerver/lib/cache.py生产环境下若var/remote_cache_prefix不存在则生成一个secrets.token_hex(16) :格式的随机前缀写入文件否则读取已有前缀。由于每次部署都会轮换前缀旧部署写入的缓存键自然成为孤儿永远不会被新代码读取从而彻底解决不同版本源代码/数据格式污染缓存的问题。配套机制还有 zerver/lib/cache.py 的update_cached_cache_key_prefixes由于清缓存需要针对所有部署前缀一次性进行见下方cache_delete_manyZulip 把从磁盘读取到的全部前缀列表自身也缓存进 memcached键为cache_key_prefixes24 小时过期且特意以compress_level0直接写入以保持跨版本兼容。删除时cache_delete_many会遍历所有前缀的笛卡尔积批量执行删除zerver/lib/cache.py保证任一版本部署的清理操作都能覆盖所有历史前缀。Django 层面缓存后端配置在 zproject/computed_settings.py默认后端为 Zulip 自定义的zerver.lib.singleton_bmemcached.SingletonBMemcached基于bmemcached客户端socket_timeout3600支持memcached_password认证与 pickle protocol 5同时保留了未启用的database后端third_party_api_results表供第三方 API 结果长期缓存使用生产配置说明见 zproject/prod_settings_template.py。六、自动化测试与 memcached每用例独立的键命名空间Zulip 的后端单元测试test-backend采用完全相同的策略每个单元测试运行前都修改KEY_PREFIX于是数千个测试用例在每次运行时都拥有互相独立的 memcached 键命名空间完全不必担心跨用例的缓存污染。bounce_key_prefix_for_testing的实现见 zerver/lib/cache.py它把测试名与进程 PID 组合后再做 SHA-1 哈希避免超过 memcached 250 字节的键长限制作为新前缀get_or_create_key_prefix在TEST_SUITE或PUPPETEER_TESTS模式下也会走专用的固定前缀路径zerver/lib/cache.py。这一点非常重要它使得 Zulip 的测试可以对某个函数/路由执行期间发生的数据库查询数、memcached 查询数做精确断言且结果始终一致——这类测试对抓出在循环里意外做数据库查询的 Bug 极为有效。同时由于前缀隔离跑后端测试不会与同一台机器上的 Zulip 开发环境互相干扰开发者调试时也无需担心某个测试改了 Hamlet 的邮箱地址导致登录失败这类蹊跷问题。更完整的全栈测试套件如test-js-with-puppeteer、test-api采用类似策略在测试运行开始时设置一个随机KEY_PREFIX。七、手动测试与 memcached开发环境的自动清空Zulip 开发环境会在provisioning初始化环境时以及启动run-dev时自动清空删除全部键memcached。如果你想禁用该行为可以用如下命令启动开发服务器tools/run-dev --no-clear-memcached即在启动开发服务器时跳过自动清空 memcached 的步骤该命令位于 tools/run-dev文档明确说明这是开发环境的手动测试场景。八、性能避免循环中的 memcached 查询关于 memcached 查询有一条与数据库查询同样的纪律绝不要在循环里逐条发起查询而应改用第四节介绍的批量函数bulk_cached_fetch/generic_bulk_cached_fetch。Zulip 的generate_bulk_cached_fetch正是为此设计的超级魔法工具它支持缓存数据写入前后做变换marshalling例如对message对象压缩后再存入缓存从而最小化 Django 与 memcached 之间的数据传输量。此外Zulip 的缓存库内置了远程缓存统计设施remote_cache_stats_start/remote_cache_stats_finish会累计 memcached 请求次数与耗时get_remote_cache_time/get_remote_cache_requests见 zerver/lib/cache.py这正是测试中断言 memcached 查询次数能力的基础。九、进程内缓存cache_for_current_request与少量进程级缓存由于 Zulip 的每个生产部署都涉及多台服务器代码库一般刻意避免使用进程内in-process后端缓存——因为不同进程/服务器间无法共享也无法保证一致性。但仍有少数例外1.cache_for_current_request单请求生命周期内的内存缓存。该装饰器把函数返回值缓存在内存中仅在同一请求的生命周期内有效。实现见 zerver/lib/per_request_cache.py以函数名建立FUNCTION_NAME_TO_PER_REQUEST_RESULT全局字典以(args, frozenset(kwargs.items()))为键做缓存flush_per_request_cache(key)与flush_per_request_caches()用于在请求开始时由中间件清空相关内存缓存。Zulip 用它对**linkifiers链接解析规则与display recipients消息展示收件人**做请求级缓存见 zerver/lib/display_recipient.py、zerver/lib/markdown/init.py、zerver/models/linkifiers.py。该模块还提供ignore_unhashable_lru_cache基于标准lru_cache的包装遇到不可哈希参数时放弃缓存并在KEY_PREFIX变化时自动清空测试场景下由bounce_key_prefix_for_testing触发。2. 构建成本高、极少请求需要、且部署后不变的少量数据缓存。例如SourceMap对象这类资源构建开销大、大多数请求用不到、且生产部署后内容不再变化适合缓存在进程内。十、浏览器端的缓存状态/register与实时事件系统Zulip 还在浏览器与移动端应用中大量缓存状态数据用户列表及其元数据姓名、头像等、频道的类似信息、近期消息历史等。这些数据通过/register端点获取Web 应用则对应page_params并在之后持续保持正确。保持这些状态不过期的关键是 Zulip 的实时事件系统详见 docs/subsystems/events-system.md服务器在客户端可能缓存的状态发生变化时会通过事件通知所有客户端客户端负责处理事件、更新自身状态并重新渲染任何展示该状态的 UI 组件。也就是说浏览器端缓存不像 memcached 那样需要主动失效而是由事件驱动的增量更新来保证最终一致。总结Zulip 缓存设计的核心心智模型回顾全文Zulip 的缓存体系可以用一句话概括读路径上通过cache_with_key等装饰器让访问器函数透明地读穿 memcached写路径上通过post_save信号让.save()自动、精准地冲刷相关缓存键部署与测试维度上通过KEY_PREFIX轮换实现彻底隔离。其工程哲学是让绝大多数开发者无感知地获得缓存收益且不引入一致性问题而把全部复杂性收敛到 zerver/lib/cache.py 与模型文件中的少数核心代码里。实践中需要牢记的四条纪律不同数据集必须用不同缓存键函数即使它们取自同一批对象变更数据的任何形态出现在缓存中该缓存键就必须被清除新增/删除对象时更要全量失效不要在循环里访问缓存或数据库一律走bulk_cached_fetch/generic_bulk_cached_fetch生产与测试都依赖KEY_PREFIX隔离不要在代码里硬编码无前缀的裸缓存键。【免费下载链接】zulipZulip server and web application. Open-source team chat that helps teams stay productive and focused.项目地址: https://gitcode.com/GitHub_Trending/zu/zulip创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考