Better Auth Redis 二级存储实战与源码解析:@better-auth/redis-storage 完整指南
【免费下载链接】better-authThe most comprehensive authentication framework项目地址: https://gitcode.com/GitHub_Trending/be/better-auth
本指南围绕 Better Auth 官方 Redis 二级存储包@better-auth/redis-storage展开,讲解它如何承载 Better Auth 的会话缓存与限流计数等二级存储职责,并沿着 CHANGELOG 的演进记录,逐层拆解其源码实现、配置参数与测试契约。读完你既能直接上手接入 Redis,也能理解SCAN枚举、Lua 原子脚本等底层原理,掌握在生产环境安全使用它的全部要点。
一、定位:Better Auth 的 SecondaryStorage 与 Redis 存储
Better Auth 在核心类型中定义了SecondaryStorage接口(见 packages/core/src/db/type.ts),用于存放会话、验证码等高频读写、可容忍丢失的临时数据。当配置了secondaryStorage后,Better Auth 会默认不再把 session 表与 verification 表写入数据库(见 packages/core/src/db/get-tables.ts),从而减轻主数据库压力;会话存储与限流计数都可以落到 Redis 这类高性能缓存中。
@better-auth/redis-storage就是这个接口的官方 Redis 实现(基于ioredis)。官方文档将其作为 Redis 二级存储的首选方案(见 docs/content/docs/concepts/database.mdx 中 Redis Storage 一节),它同样适用于把rateLimit等插件的数据层放到 Redis 上做分布式限流。
二、安装与最小接入
安装包本身非常轻量,源码仅两个文件(index.ts 导出入口与 redis-storage.ts 核心实现):
npm install @better-auth/redis-storage从 package.json 可以看到它的运行时依赖关系:
| 依赖 | 类型 | 说明 |
|---|---|---|
@better-auth/core | peerDependency | 提供SecondaryStorage类型契约,版本随主仓库同步 |
ioredis | peerDependency(^5.0.0) | Redis 客户端,由使用方自行安装并传入实例 |
接入示例(完整可运行):
import { Redis } from "ioredis"; import { redisStorage } from "@better-auth/redis-storage"; const redis = new Redis({ host: "localhost", port: 6379, }); const auth = betterAuth({ // 会话、验证码等临时数据全部落到 Redis secondaryStorage: redisStorage({ client: redis }), // 可选:让限流计数也使用 Redis,实现多实例共享的分布式限流 rateLimit: { enabled: true, storage: "secondary-storage", }, });说明:
rateLimit插件的storage: "secondary-storage"配置依赖secondaryStorage已启用;increment方法正是为此而设计(详见下文)。
三、配置项:RedisStorageConfig 完整说明
RedisStorageConfig定义在 redis-storage.ts,只有两个字段:
| 参数 | 类型 | 必填 | 默认值 | 作用 |
|---|---|---|---|---|
client | Redis(ioredis 实例) | 是 | — | 所有 Redis 操作均通过该客户端发出 |
keyPrefix | string | 否 | "better-auth:" | 所有键的统一前缀,用于隔离多租户/多应用数据 |
// 使用自定义前缀,避免与业务数据互相污染 redisStorage({ client: redis, keyPrefix: "my-app:auth:", });从源码看,prefixKey会对每个键做keyPrefix + key拼接(redis-storage.ts),也就是说所有实际写入 Redis 的键都带上前缀,而对外暴露的key则保持不带前缀的"逻辑名"。这个前缀不仅在get/set/delete时生效,更在listKeys()/clear()的SCAN匹配与去前缀逻辑中承担关键角色(见下文第四节)。
四、接口契约:SecondaryStorage 的六个方法
redisStorage()的返回值满足SecondaryStorage接口,并额外扩展了listKeys与clear两个方法。对照 type.ts 的类型定义,逐方法说明其语义:
| 方法 | 签名 | 语义 |
|---|---|---|
get | (key) => unknown | 读取键值 |
getAndDelete | (key) => unknown | 原子地读取并删除,用于验证码等一次性数据 |
increment | (key, ttl) => number | 原子地自增计数并返回新值;键不存在时以1创建并设置ttl(秒),TTL 只在创建时设置、后续自增不续期 |
set | (key, value, ttl?) => void | 写入;ttl为正数时使用SETEX,否则使用SET |
delete | (key) => void | 删除单键 |
listKeys/clear | 扩展方法 | 列出/清空当前前缀下的全部键 |
关键点:increment是接口的硬性要求,注释明确写到"二级存储限流需要在一次分布式安全的操作中完成计数",这正是rateLimit插件依赖它的原因。
五、源码深挖:每个方法背后的 Redis 实现
5.1 getAndDelete:GETDEL 优先 + Lua 兜底
redis-storage.ts 中,getAndDelete优先使用 Redis 6.2+ 的原生GETDEL命令(通过client.call("GETDEL", key)调用):
- 成功则直接返回,不产生额外的网络往返;
- 若抛出"unknown command"类错误(如 Redis 版本 < 6.2),则降级为 Lua 脚本实现同等的"取值并删除"原子语义,并把
supportsGetDel置为false,后续调用不再重试原生命令; - 其他错误(如
Authentication required)直接向上抛出,不做降级,避免掩盖真实故障。
兜底的 Lua 脚本(redis-storage.ts):
local value = redis.call("GET", KEYS[1]) if value ~= false then redis.call("DEL", KEYS[1]) end return value这个"探测-降级-缓存结果"的模式保证了一致性:先探测一次失败后,同一进程内不再发起无意义的失败调用。
5.2 increment:固定窗口限流的原子计数
increment使用 Lua 脚本实现"自增 + 仅在创建时设置过期时间"(redis-storage.ts):
local value = redis.call("INCR", KEYS[1]) if value == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return value其核心设计意图正是 CHANGELOG 中 1.6.17 条目描述的修复:
由 Redis 二级存储支撑的限流窗口不再因持续流量而延长;窗口的过期时间在窗口开启时一次性设定。
也就是说,只有第一次INCR(返回 1,说明窗口刚刚打开)才设置EXPIRE;后续自增不会重置 TTL,从而保证限流窗口是固定时长的,而不是"滑动续期"。此外,increment在调用 Redis 之前还会校验ttl必须是正整数,否则直接抛出TypeError(redis-storage.ts),避免把非法参数发给 Redis。
5.3 set:SETEX 与 SET 的分流
redis-storage.ts 中,ttl !== undefined && ttl > 0时走client.setex(key, ttl, value)(一次往返完成"写值+设过期"),否则走client.set(key, value)写入永不过期的键。会话缓存场景下建议始终传入 TTL,避免 Redis 中堆积过期键。
5.4 listKeys 与 clear:用 SCAN 替代 KEYS
这是 CHANGELOG 1.6.26 条目记录的一次重要修复,也是整个包最值得关注的安全与性能细节。旧实现使用KEYS命令,而KEYS会同步遍历整个键空间,在数据量巨大时直接阻塞 Redis 服务;新实现改用SCAN游标分批遍历(redis-storage.ts):
async function* scanBatches(): AsyncGenerator<string[]> { let cursor = "0"; do { const [nextCursor, batch] = await client.scan( cursor, "MATCH", `${escapedPrefix}*`, "COUNT", SCAN_COUNT, // SCAN_COUNT = 100,每批采样 100 个键 ); cursor = nextCursor; if (batch.length > 0) yield batch; } while (cursor !== "0"); }其中有两处容易被忽略的细节:
- 前缀的 glob 转义(redis-storage.ts):
SCAN的MATCH参数是 glob 模式,若keyPrefix含有* ? [ ] \等元字符,未转义时会误匹配前缀之外的键,导致clear()误删其他业务数据。实现通过keyPrefix.replace(/[\\*?[\]]/g, "\\$&")将元字符转义为字面量,而实际存储键仍使用未转义的原始前缀,因此keyPrefix.length仍是listKeys去前缀时正确的截取长度。 - 空库安全:
scanBatches只产出非空批次,clear()对空库是安全的 no-op,不会发出参数为空的DEL(Redis 会拒绝零参数DEL并报ERR wrong number of arguments)。
listKeys()在聚合批次时用Set去重(SCAN 可能跨页重复返回同一键),并裁掉前缀后返回逻辑键名(redis-storage.ts);clear()则逐批DEL(redis-storage.ts)。
必须注意clear()的非原子语义:源码注释与测试都明确约定,clear()是"尽力而为"的——遍历过程中若 Redis 报错或连接中断,前面已删除的批次不会回滚,Promise 以 reject 结束,此时"未知子集的键可能已经没了"。同时由于 SCAN 是弱一致枚举,并发写入时一次成功的clear()也不代表库一定为空。因此需要"确保完全清空"(如撤销全部会话或限流计数)的调用方应当重试直到成功。
六、测试契约:行为即文档
测试文件 redis-storage.test.ts(基于 vitest + mock 的 ioredis 客户端,无需真实 Redis 实例)把上述每个行为都固化为契约,值得逐条对照:
| 测试用例 | 锁定的行为 |
|---|---|
| "uses GETDEL when it is supported" | 支持 GETDEL 时只调call("GETDEL", ...),不触发 Lua |
| "falls back to Lua when GETDEL is unavailable" | 首次 unknown command 后降级 Lua,且只探测一次 |
| "rethrows GETDEL errors that are not unknown-command" | 非 unknown 错误直接抛出不降级 |
| "increments atomically and sets the ttl only on creation" | 首次increment设 TTL=60,后续自增到 2、3 不重置 TTL |
| "clears every prefixed key by paging through SCAN" | clear()全程不调用KEYS,按游标分页DEL |
| "does not call DEL when clearing an empty store" | 空库清空是 no-op,不发出零参数 DEL |
| "propagates a mid-iteration failure, leaving earlier pages deleted" | 明确固化"中途失败、前面批次已删"的非原子契约 |
| "lists keys via SCAN, stripping the prefix and deduping" | 跨页重复键去重、前缀剥离、顺序不保证 |
| "escapes glob metacharacters in the prefix" | 前缀ba[1]:转义为ba\[1\]:*后再做 SCAN 匹配 |
| "rejects invalid ttl ... before mutating Redis" | ttl 为 0/-1/1.5/NaN/Infinity 时直接抛错且不触达 Redis |
运行测试的命令(见 package.json):
pnpm --filter @better-auth/redis-storage test七、CHANGELOG 演进解读:这个包是怎么变成现在这样的
对照 CHANGELOG.md 的版本记录,可以还原该包的两条关键演进主线(其余绝大多数条目为随@better-auth/core的依赖同步升级,本包自身无功能变更):
1.6.17 —— 引入原子increment(PR #9993)
- 背景:Redis 二级存储支撑的限流窗口原先可被持续流量"续期"延长,导致限流形同虚设;
- 修复:窗口过期时间在窗口开启时一次性设定,后续自增不再续期;
- 落地:
SecondaryStorage接口新增increment方法(type.ts),Redis 实现用 Lua 脚本原子完成"自增 + 仅创建时设 TTL"。
1.6.26 —— SCAN 取代 KEYS,前缀转义与空库安全(PR #10507)
listKeys()与clear()改为SCAN游标分页枚举,避免大键空间下KEYS阻塞 Redis 服务;- 对
keyPrefix中的 glob 元字符做转义,防止clear()误删前缀之外的键; - 空库时
clear()为安全的 no-op。
到当前版本1.7.3(package.json),该包与@better-auth/core保持同版本号发布节奏,使用方升级主包时需同步升级此包以保证接口契约一致。
八、生产实践要点小结
- 固定窗口限流:依赖
increment的"仅创建时设 TTL"语义,限流窗口时长固定、不会被流量续期;计数与过期在一个 Lua 脚本内原子完成,多实例部署下天然一致。 - 前缀隔离:多环境、多租户共用一个 Redis 时务必设置独立
keyPrefix(默认better-auth:),同时注意前缀中不要出现* ? [ ] \等字符,或者信任实现已做好的转义处理。 - 版本匹配:
@better-auth/redis-storage与@better-auth/core同版本发布,升级时两者需保持一致。 - 清库语义:
clear()非原子、尽力而为,需要彻底清空时应重试;对 Redis < 6.2 的环境,getAndDelete会自动降级 Lua,无需额外配置。 - 只读仓库提醒:以上均为查看、安装与配置方式;如需修改包行为,请在自己的项目中 fork 或封装,不要改动当前仓库。
九、继续深入
- 包源码:redis-storage.ts、index.ts
- 单元测试(行为契约):redis-storage.test.ts
- 接口定义与限流语义:packages/core/src/db/type.ts
- 官方数据库概念文档(含 node-redis 与 Upstash Redis 的自实现示例):docs/content/docs/concepts/database.mdx
- 表结构裁剪逻辑(配置二级存储后自动跳过 session/verification 建表):packages/core/src/db/get-tables.ts
- 版本演进记录:CHANGELOG.md
【免费下载链接】better-authThe most comprehensive authentication framework项目地址: https://gitcode.com/GitHub_Trending/be/better-auth
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考