next-shared-cache源码分析

April 29, 2024

Next暴露了Cache Handler(Custom Next.js Cache Handler),允许用户自定义第三方外部存储来共享page、route cache数据,避免本地文件、内存存储。 next-shared-cache 就是其中的一款实现,它的目的是简化cache handler的实现,同时也提供了基于redis的开箱即用存储组件。

背景


之前在项目因为next的内存占用过高,所以计划将next的data cache数据迁移到外部存储cache-handler(实际上最后发现占用内存大头的并非next cache),在后台项目中多机共享集中式cache的场景也比较常见了,所以引入了 next-share-cache 中的 redis-strings 存储handler。引入后内存确实有一定的降低,也提升了项目的整体稳定性(也担心内存、磁盘IO过多影响next server)。

在引入 next-shared-cache 的过程中遇到了数据过期时间点的耗时毛刺问题,咨询作者过程中了解到 next-shared-cache 也有stale-while-revalidate的能力,就很好奇在next上是如何实现的。据我了解next不具备异步执行任务的能力(在vercel上会被kill,这也算react商业化带来的不足吧)。所以打算读一遍代码,一方面为了后续的扩展,另一方面也想了解下其底层实现。

Cache Handler实现

官网上其实介绍如何实现cache handler的内容非常少。只是列举了几个API

set:

写数据到缓存。比如unstable_cache()从backend获取的数据,可以写入缓存,供下次请求使用。官方的文档给了3个参数:

  • key:缓存key。如果为page缓存,其key为URL。fetch类型的数据为加密过的字符串。
  • data:next内部结构化过的数据,包括了fetch结果数据。
  • context:包括tag、revalidate参数。用于实现revalidate机制。即用户侧不仅要存储key→data的数据结构,还要存储tag → key[]的倒排索引。当执行revalidate tag时要确保对应的key数据被清理掉。

比较奇怪的是文档上没漏出revalidate。大概是官方希望用户自己控制stale机制,避免与next的内部实现关联。

get:

从缓存取数据。数据过期时用户侧没有什么好的办法避免耗时毛刺,在next-cached-share中巧妙的利用了next的lifespan机制,来实现stale while revalidate机制。

revalidateTag:

根据tag过期数据。

next-shared-cache基本用法

用户侧自己实现set、get以及tag管理还是比较繁琐的事情,next-shared-cache的目的就是为了更加方便的cache handler接入。用户仅需要实现onCreation hook即可,不需要关注如何读写以及revalidation如何实现。典型用法如下:

Loading...

上面为nextBlogger中使用redis的用法,开发者仅需要关注redis如何连接即可,不需要去关注读写以及revalidation的API实现。使用连接生成对应类型的handler,然后丢给CacheHandler来统一管理调度。

  • 支持handler列表,即支持多个存储中间件并存。当redis失败时可以使用本地内存兜底。
  • 参数化过期策略手段,相对自己手工去实现get、set等接口来对开发者来说成本极小。

源码分析


next中CacheHandler定义如下;

Loading...

所以实现上, next-shared-cache就是对next中的CacheHandler的继承,其内部分为两层:

💡
Handler:代表各存储后台,实现各类存储类型的基础读写能力,包括redis string handler、redis stack handler以及lru handler,这部分各自根据自己的存储类型,实现具体的 setget以及 revalidateTag 操作。
💡
CacheHandler:提供给next的cache handler实现,管理各个handler。

Handler


handler负责实现各种存储类型的底层接口实现。其基本API包括

Loading...

next-shared-cache 中实现了三种类型的handler,以 redis-strings handler为例,分析其内部实现。

构造函数

redis-strings 提供了createHandler来生成handler,必须提供的参数如下

  • client:用户侧传入的redis连接
  • keyPreifix:在redis中的key前缀,避免冲突
  • sharedTagsKey:存储key与tag的映射关系,数据结构为redis hashtable,存储key对应的tag列表。貌似没有tag到key的反向索引?
  • timeoutMs:命令字超时时间
  • keyExpirationStrategy:对应redis的命令字。redis中设置过期时间有两种方法,老版本只能通过set、expire两个独立的命令字完成,新版本通过set携带过期时间一次调用即可。推荐使用EXAT。

name

固定字符串,表示handler的名字。

set

将数据key、value以及过期时间写入redis string。其中key为原始key加上prefix。value cacheHandlerValue 数据结构,JSON.stringify后的字符串写入。

写入key、value的同时也会向 sharedTagsKey hash中写入key对应的tag列表。

看我的redis中JSON.stringify后的string是真的长,快1M大小了,尤其是Route Cache的页面结果,感觉这里有更优的方案,比如采用gzip等性能开销不那么大的压缩方案(like leveldb)。在大key、value的数据时候优化存储结构,性能允许的情况下降低存储空间。
Loading...

get

读取key、value,校验格式长度有效性。然后根据tag列表从 revalidatedTagsKey hash结构中取出各tag的revalidate的时间戳,逐个对比是否过期。只要有一个tag满足过期,则表明数据不符合条件,返回null给到上层即可。

Loading...
PS: unlink已经为较新的redis版本了吧。

revalidateTag

将特定tag的数据删除。流程如下:

  • 使用curson读取出hashtable sharedTagsKey下的所有key → tag[] 数据。
  • 逐个校验key的tag列表中是否包含要要清理的key,如果包含则将key加入要删除的key列表,同时将key加入要清理的sharedTags列表。
  • 从string中删除key/value数据。从sharedTagsKey 删除要清理的key/tags[]数据。

revalidateHashTag的特殊处理

revalidateHashTag是一个独立的数据结构,存储tag到过期时间的映射。这类tag比较特殊,以 N_T 打头,被成为implicit tag,next内部使用。特殊逻辑:

  • revlidateTag中,如果发现为implicit tag,仅在 revlidateHashTag hashtable中记录过期时间(当前时间),不执行key清理
  • get中会根据读取出的value.tags列表,逐个校验是否满足 revalidatedTagsKey 过期条件。

从存储数据上看,implicit tag主要用作full route cache:

Loading...

猜测其作用与data cache和full route cache的映射关系有关,但是没有文档说明。

💡
Revalidating or opting out of the Data Cache will invalidate the Full Route Cache, as the render output depends on data.

CacheHandler


CacheHandler的作用,是根据用户指定的handler以及其他配置,包装一份完整的Next规范的cache handler实现。

典型用法如下:

Loading...

onCreation

暴露给用户的唯一静态方法,用于指定handler及其配置的hook,即上面的handler仅在使用时才会被创建。

hook的具体执行触发点在下面的configCacheHandler中。

构造函数constructor

保存next传入的context,在hook被调用时取出部分数据传递给hook构造handler时使用。next中的原始context数据结构包括以下字段:

Loading...

onCreation中仅保留了以下字段给到hook:

  • serverDistDir

描述:Next.js服务器目录的绝对路径。其值从next的context中继承。

目的:该路径对于定位可能需要缓存实现的服务器端资源和文件(例如用于在文件系统中存储缓存数据的目录)至关重要。

  • dev

描述:指示Next.js应用程序是否处于开发模式。其值从next的context中继承。

目的:可以使用此标志根据环境更改缓存行为。

  • buildId

描述:Next.js应用程序当前构建的唯一标识符,在下次构建过程中生成。build完毕后在 .next 目录下存在一个BUILD_ID的文件,其内容就是本次的buildId。

目的:可以将buildId用作缓存键的命名空间的前缀,确保每个应用程序构建的缓存数据都是独特且相关的。避免编译新版本时cache无法向前兼容的情况。

configureCacheHandler

执行onCreation中指定的hook,根据hook返回的handlers列表、ttl值配置缓存处理器,仅会被执行一次,在set、get等API中触发(可以理解为懒执行)。

hook返回的数据字段包括:

  • handlers:用户指定的handler列表(同写同删,仅读一个)
  • ttl
    • defaultStaleAge:默认值1年,未指定 revalidate 时用作staleAge的兜底。
    • estimateExpireAge:根据过期时间计算缓存数据在介质中真实过期时间的callback,可以返回固定值,也可以根据staleAge参数估算(比如✖️10倍)

主要用于stale-while-revalidate场景,避免缓存过期时的耗时毛刺。但是如果时间过长,也要考虑数据量的影响。

💡
estimateExpireAge的存在主要是考虑到一种场景:当缓存数据已经过期,下次请求时需要next需要重新从backend server取数据,再写入cache后返回给终端,整体耗时太长。而 estimateExpireAge的目的就是为了解决此类问题,当数据更新时间 < estimaExpireAge callback返回的时长时,可以认为数据本次可用,会先将cache中的数据返回给用户,然后再使用后台异步更新的逻辑完成数据的刷新。

配置的缓存处理器存储在内部变量 CacheHandler.#mergedHandler 中,其中包括了 setgetrevalidateTag三个API,主要供CacheHandler中对外的set、get以及revalidateTag使用:

  • #mergedHandler.set :批量调用各handler的set接口,即一次写入会写入所有handler对应的存储。
  • #mergedHandler.get :遍历handler,从任意handler中get到数据即返回(因为理论上各handler中存储的数据值以及过期时间是完全一致的)。同时也会校验过期时间 lifespan.expireAt ,如果过期则调用handler的delete接口(如果有)清理数据。
    • 上面的redis-strings handler实现就没有提供delete接口。因为redis自身有过期机制保障数据被清理。可能有些存储中间件就无法提供了,比如 mysql 等。
  • #mergedHandler.revalidateTag 与set操作类似,批量调用各handler的revalidateTag接口。

set()

目的:暴露给next的缓存写接口。key为string,next传入的value类型为 IncrementalCacheValue 对象,会根据其类型( Kind )不同有不同的数据结构。缓存测通常不需要关心。

💡
缓存对象的类型可能为:REDIRECT、PAGE、IMAGE、FETCH、ROUTE。

CacheHandler在 IncrementalCacheValue 上做一层封装,增加3个字段后写入各个handler。重点关注lifespan

Loading...

set的主要逻辑就是在next原value结构的基础上扩展lifespan等参数,然后批量写入各handler的key→value、key→tag[]存储。

流程

  • 配置configCacheHandler(only once)
  • getLifespanParameters, 计算出lifespan对象,作为CacheHandlerValue的一部分会被写入各handler。
    • lastModifiedAt:最近更新时间,即系统时间
    • staleAge:数据有效时长s,即next中的 revalidate 。如果无则使用用户传入的 #defaultStaleAge (默认1年)
    Loading...
    • staleAt:lastModifiedAt + staleAge
    • expireAge:estimateExpireAge callback计算出的真实缓存过期时间。默认等于数据有效时间staleAge。注意mergeHandler.get中对比的的也是此字段。
    • expireAt: lastModifiedAt + expireAge
  • 对部分类型的数据特殊处理
    • PAGE:tag从header['x-next-cache-tags’]获取
    • ROUTE:将body字段从Buffer转换为base64,节省空间?
  • 构造写入cach的value对象,调用#mergedHandler.set 将数据批量写入各缓存handler中
Loading...

get()

流程:

  • configCacheHandler(only once)
  • 调用#mergedHandler.get 取数据。即从任意handler中读取到有效数据即可。
  • fallbackFalseRoutes 的特殊处理?

注意返回给next的数据类型为CacheHandler扩展过的 CacheHandlerValue (扩展了lifespan等参数),而非next原始传入set的IncrementalCacheValue。在next官网上没有找到lifespan参数的说明,咨询了next-share-cache的作者,其传入lifespan就是为了让next帮助其实现stale-while-revalidate的能力。

revalidateTag()

调用#mergedHandler.revalidateTag 即可,无特殊逻辑。

其他


next-shared-cache 的实现与next内部的data cache实现耦合比较深,从中可以了解部分的next的data cache的file system缓存机制,以及SWR等能力。虽然离深入next具体的实现原理也还比较远,但是如果有定制cache handler的需求,还是可以阅读下源码。也能大致了解自己的缓存中间件中存储的到底是什么。

另外next-shared-cache 的工程化做得不错,大仓、单测、e2e、git action、lint工具建设等都比较完善。也可以作为前端工程化的学习样本来阅读。后续有时间也可以单独输出一篇。

See all postsSee all posts