加载笔记列表卡了 2 秒,我盯着屏幕懵了
上周练手搭了个 Next.js 笔记项目,左侧笔记列表,右侧编辑区,用 Redis 当存储。选 Redis 是因为之前只在缓存场景用过它,想正经用它当一回主存储,顺便把 ioredis 撸明白。
项目脚手架 create-next-app 跑起来,路由搭好,组件拆分完,数据层写了一版,启动 next dev,点开页面------sidebar 加载卡了 2 秒,而且列表全是空白笔记。
我第一反应是:Redis 连不上?
第一个坑:把 Redis 当成了 localStorage
之前对 Redis 的印象就是 key-value,跟浏览器的 localStorage 一个德行。我就按 localStorage 的思路写了笔记存取:
javascript
// 我第一版的 Redis 存取逻辑
await redis.set('note:' + id, JSON.stringify(note));
const data = await redis.get('note:' + id);
单条笔记的存和取都挺顺当,问题出在获取全部笔记 这一步。localStorage 里你可以用 Object.keys(localStorage) 拿到所有 key,但 Redis 没有这种全局遍历。我当时的反应是:不就是遍历 key 吗,Redis 肯定也有类似的东西。
查文档查到了 KEYS 命令:
javascript
// 错误的获取全部笔记的方式
const keys = await redis.keys('note:*');
const values = await redis.mget(keys);
// 还要自己拼回 {id: note} 的结构
next dev 跑起来确实能拿到数据,但我注意到一个问题:3 条笔记还行,要是有几百条呢?KEYS 命令会扫描所有 key,数据量大的时候直接阻塞 Redis。更麻烦的是,我要删除所有笔记,得先 KEYS 拿到列表,再 DEL 每个 key,循环发命令。
跑了个 100 条笔记的模拟,列表加载从 2 秒变成了 5 秒,还伴随 Redis CPU 飙高。说实话,那一刻我在心里骂了一句:这玩意儿比 MySQL 还难用?
翻完 Redis 文档我才明白,Hash 才是正确姿势
Redis 不是只有一种 key-value 存储方式,它支持 5 种基础数据结构:String、Hash、List、Set、Sorted Set。我之前只用过 String(SET/GET),完全忽略了 Hash 类型的存在。
Hash 是什么东西?用一个生活化的类比:String 存储就像把文件散落在桌面上,每个文件单独命名;Hash 存储则是一个文件柜------key 是文件柜的名字,里面每个抽屉(field)放一个文件(value)。
当你只有两三个文件的时候,散落桌面和文件柜区别不大。但当你有几十个文件要整理、要整体搬走的时候,文件柜的优势就出来了------你搬一个文件柜就行,不用一个个搬文件。更重要的是,如果你要统计文件总数,散落桌面得一个个数,文件柜只要拉开柜门扫一眼。
这个类比当时我在脑子里过了三遍才彻底想通。之前对 Redis 的印象就是「高级的 localStorage」,key-value 存就完了。没想到 Redis 的设计者在一个 key-value 的壳子下面,藏了这么多针对不同场景的优化结构。
javascript
// String 存法:像散落在桌面的文件
{
"note:1702459188837": '{"title":"ea molestias",...}',
"note:1702459182837": '{"title":"qui est",...}'
}
// Hash 存法:像一个文件柜
{
"notes": {
"1702459188837": '{"title":"ea molestias",...}',
"1702459182837": '{"title":"qui est",...}'
}
}
对比一下操作:
| 场景 | String 命令 | Hash 命令 |
|---|---|---|
| 存一条笔记 | SET note:id value |
HSET notes id value |
| 取一条笔记 | GET note:id |
HGET notes id |
| 取全部笔记 | KEYS pattern + MGET |
HGETALL notes |
| 删一条笔记 | DEL note:id |
HDEL notes id |
| 删全部笔记 | 遍历 + DEL |
DEL notes |
看看 Hash 在 Redis 里实际存的样子,跟 String 散存的区别一目了然:

左边是 Hash 存法,一个 notes key 下面挂 3 个 field;右边如果用 String 存,就是 3 个独立的 note:xxx key。切换成 Hash 存储之后,代码变成了:
javascript
export async function getAllNotes() {
// Hash 方式:一个 hgetall 搞定全部笔记
const data = await redis.hgetall("notes");
if(Object.keys(data).length === 0) {
await redis.hset("notes", initialData);
}
return await redis.hgetall("notes");
}
就这一小段代码,hgetall 直接返回整个 Hash 的所有字段和值------一个纯 JS 对象,Object.entries 一转就能 map 渲染。3 条笔记的加载时间从 2 秒降到了毫秒级。
javascript
// 实际运行结果
// console.log(await getAllNotes())
// {
// "1702459181837": '{"title":"sunt aut","content":"quia et suscipit...","updateTime":"2023-12-13T09:19:48.837Z"}',
// "1702459182837": '{"title":"qui est","content":"est rerum tempore...","updateTime":"2023-12-13T09:19:48.837Z"}',
// "1702459188837": '{"title":"ea molestias","content":"et iusto sed quo...","updateTime":"2023-12-13T09:19:48.837Z"}'
// }
HGETALL是 Hash 类型的杀手锏命令------一次网络往返拿全部数据,返回值就是纯 JS 对象,不需要任何中间转换。笔记、配置项、权限映射等「一组关联的 key-value 数据」都应该优先考虑 Hash。
第二个坑:直接存对象,取出一堆 object Object
Hash 搞通之后,我在写新增笔记的接口时犯了一个低级错误------直接把 JS 对象丢进了 hset:
javascript
// 错误写法:直接存对象
await redis.hset('notes', id, { title: '新笔记', content: '内容', updateTime: '2024-01-01' });
Redis 是纯文本存储,不认识 JS 对象。存进去的是 [object Object],取出来也是 [object Object],页面上全是乱码。
说实话,当时我盯着这个字符串看了好几秒才反应过来:JSON 序列化忘写了。Redis 的 Hash field value 只能是字符串,存对象必须先 JSON.stringify:
javascript
// 正确写法:先序列化
await redis.hset('notes', id, JSON.stringify({
title: '新笔记',
content: '内容',
updateTime: '2024-01-01'
}));
取出来之后记得 JSON.parse 转回去:
javascript
// SidebarNoteList.js 里
const arr = Object.entries(notes);
arr.map(([noteId, note]) => {
// note 是字符串,必须 parse
const parsed = JSON.parse(note);
return <SidebarNoteItem noteId={noteId} note={parsed} />;
});
我第一遍写 SidebarNoteList 的时候忘了 JSON.parse,note.title 永远是 undefined,列表全显示空白。查了半天才发现------hgetall 返回的每个 field value 都是原始字符串,不会自动帮你反序列化。

打开 Redis 客户端确认了一下:notes 这个 Hash key 下有 3 个 field,每个 field 的 value 就是一行 JSON 字符串,正是我 JSON.stringify 后的结果。那个瞬间我终于搞明白了 Redis Hash 的工作方式------它就是一个 field-value 的字典,所有 value 都是纯字符串,没有任何类型系统。
第三个坑:在 Server Component 里写错了取数方式
搞定 Redis 数据层,我开始写 Sidebar 组件。Next.js App Router 默认组件就是 Server Component,支持 async,可以直接 await 数据请求。我写了这样一版:
javascript
// Server Component,可以是 async 的
export default async function Sidebar() {
const notes = await getAllNotes();
return (
<section className="col sidebar">
<nav>
<SidebarNoteList notes={notes} />
</nav>
</section>
);
}
跑起来没问题,但当我想在 SidebarNoteItem 里加个展开/收起的交互效果时,问题来了------交互需要 useState,而 Server Component 不能用 React Hook。
我第一反应是给 Sidebar 加 "use client" 指令,把整个组件变成 Client Component:
javascript
"use client";
// 错误:Client Component 不能 import Node.js 专属模块
import { getAllNotes } from "@/lib/redis";
export default function Sidebar() { ... }
next dev 直接报错:ioredis module cannot be imported by client components。道理很简单------Client Component 的代码最终会打包到浏览器里,而 ioredis 是 Node.js 的 Redis 客户端,浏览器根本跑不了。
正确做法是拆分边界:数据获取留在 Server Component,交互逻辑下沉到 Client Component。具体来说:
arduino
Sidebar (Server, async) ← 拉 Redis 数据,传 props
→ SidebarNoteList (Server) ← 转数据结构,遍历
→ SidebarNoteItem (Server) ← 渲染基础信息
→ SidebarNoteItemContent (Client, "use client") ← 放 useState
Server Component 负责把 Redis 数据拉过来、通过 props 一层层往下传;Client Component 只负责交互逻辑,不碰任何服务端 API。这样既利用了 RSC 的服务端渲染能力(SEO 友好、零客户端数据请求),又保留了 CSR 的交互性。
hgetall 返回的到底是什么结构?
ioredis 的 hgetall 返回的不是什么复杂结构,就是一个扁平的 JS 对象:
javascript
const data = await redis.hgetall('notes');
// data = {
// "1702459181837": '{"title":"sunt aut",...}',
// "1702459182837": '{"title":"qui est",...}',
// "1702459188837": '{"title":"ea molestias",...}'
// }
注意 key 是 Redis Hash 的 field 名(笔记 ID),value 是存入时的原始字符串。要用 Object.entries 转成 [[id, value], [id, value], ...] 的二维数组才能 map 渲染。我第一次直接 Object.keys(data).map(...),结果拿不到 value,折腾了好一阵才反应过来。
Hash 为什么比 String 存法更快?
这个问题我特意查了 Redis 源码层面的设计。Redis 对 Hash 类型做了特殊优化:当 Hash 的 field 数量少于 hash-max-listpack-entries(默认 128)且每个 value 长度小于 hash-max-listpack-value-length(默认 64 字节)时,Redis 会使用一种叫 listpack(旧版叫 ziplist)的紧凑编码方式存储。
简单来说就是:
- 内存更省:一个 Hash 的多个 field 在内存中是连续存储的,省去了每个 String key 都有的元数据开销(过期时间、引用计数等)
- 访问更快 :
HGETALL只需要一次网络往返,而多个 String key 的KEYS + MGET至少需要两次往返,还要处理游标 - 原子操作 :
HSET一个 Hash 的多个 field 是原子的,不会出现写了一半崩了的情况
对笔记系统这种场景(笔记数量不会上万条,每条笔记的 JSON 也不算很长),Hash 几乎完美契合。
这个项目用 Redis 存笔记,合适吗?
写到这里其实有一个疑问:笔记系统应该用 Redis 当主存储吗?
答案是:练手项目没问题,生产环境不合适。
Redis 本质是内存数据库,数据存在 RAM 里,虽然有持久化机制(RDB/AOF),但它的设计目标不是替代 MySQL 这种关系型数据库,而是做它擅长的事:
- 缓存:热点数据存在 Redis,减少数据库压力。比如掘金首页的文章列表,第一次请求查 MySQL 后把结果存 Redis,后续请求直接从 Redis 读
- 计数器 :文章阅读数、点赞数,用 Redis 的原子自增(
INCR)比数据库UPDATE高效得多 - 排行榜:用 Sorted Set 做 ZSET,天然支持按分数排序和分页
如果笔记数据丢了能恢复(比如从 MySQL 同步一份过来),那 Redis 可以当缓存层;但如果笔记是核心业务数据,绝不能只存在 Redis 里。
顺手捋的核心知识点
写到这顺手把核心的点捋了一遍,我自己回头复盘方便,你们要是懒得翻长文也可以直接看这部分。
1. Redis Hash 数据结构的选择
- 核心结论:多个关联的 key-value 数据用 Hash 而非独立 String key,通过
HSET/HGET/HGETALL/HDEL操作 - 易错提醒:别用
KEYS+MGET模拟批量查询,KEYS会阻塞 Redis,正确姿势是HGETALL一次拿全部
2. Redis 存值必须序列化
- 核心结论:Redis 是纯文本存储,所有值都是字符串,存对象必须
JSON.stringify,取出来必须JSON.parse - 易错提醒:直接存 JS 对象会得到
[object Object],页面渲染全是 undefined,且没有任何报错提示
3. ioredis hgetall 的返回结构
- 核心结论:
hgetall返回扁平对象{field: value},value 是原始字符串,需用Object.entries转数组遍历 - 易错提醒:返回值不是嵌套的 Map 或 Set,就是普通 Object,直接
Object.keys().map()拿不到 value
4. Next.js RSC 与 Client Component 的边界
- 核心结论:Server Component 是 async 的,可直接
await服务端数据源;Client Component 用"use client"声明,不能 import Node.js 专属模块 - 易错提醒:不要给包含服务端逻辑的组件加
"use client",应该拆分:数据获取在 Server Component,交互在 Client Component
5. Redis Hash 的性能优化原理
- 核心结论:Hash 在 field 数量少(≤128)且 value 短(≤64 字节)时用 listpack 紧凑编码,内存和访问速度都优于分散的多个 String key
- 实现步骤:① 选定 Hash key 名(如
notes)②HSET批量写入 field-value ③HGETALL读取全部 ④ 遍历后JSON.parse转对象
6. Redis 的适用场景边界
- 核心结论:Redis 适合缓存、计数器、排行榜等「丢了能恢复」的场景,不适合存需要事务和强一致的核心业务数据
- 易错提醒:不要因为 Redis 快就用它当唯一数据源,生产环境应该 Redis + MySQL 配合使用
最后说两句
这个笔记项目跟着 Next.js 官方教程改的,原教程用 SQLite,我换成了 Redis。替换的过程中才真正理解了一件事:数据库不是只有 SQL 和 NoSQL 之分,NoSQL 内部的不同数据结构也有各自的最佳使用场景。
我之前对 Redis 的理解就是「一个比 MySQL 快的缓存」,现在才明白它的 5 种数据结构是针对不同场景设计的------Hash 适合对象存储、List 适合消息队列、Set 适合标签去重、Sorted Set 适合排行榜。选错数据结构,轻则性能差,重则数据全乱。
反正我现在写代码会先想一下:我存的这堆数据之间有关系吗?有关系的话,是不是该用 Hash 而不是散落在不同的 key 里?你们踩过同款坑吗?