哈希表的业务实战:从图片加载失败到相关笔记,小红书的三个处理范式
哈希表不只是 O(1) 查找。以小红书为例,看它如何在图片加载失败兜底、笔记相关推荐、Feed 流去重这些具体业务里被使用。
为什么业务里到处是哈希表
面试背过一百遍「哈希表查找 O(1)」,但真到业务代码里,很多人还是满屏 array.find()。这篇文章换个角度:不画原理图,只看业务现场。以小红书的图文社区场景为例,三个最高频的处理范式,每一个背后都是哈希表。
先给结论——业务代码选择哈希表的三个理由:
- 「按 ID 找东西」是最高频的操作:找一篇笔记、找一个用户状态、查一张图加载过没有
- 「去重」是第二高频的操作:Feed 不推重复内容、已读不再红
- 前两者的组合——「ID → 状态」的映射,几乎就是所有客户端缓存的本体
范式一:图片加载失败的处理 —— Map<url, failCount>
信息流里图片加载失败是家常便饭:弱网、CDN 抽风、图被删。天真的处理是 onerror 时换占位图,但有两个真实问题:
问题 1:无限重试闪屏。 某些环境下 onerror 后组件重渲染又触发加载,再次失败再次换图,用户看到的是疯狂闪烁。
问题 2:重复犯错。 用户滑走再滑回,同一张失败 URL 重新加载一遍,重新失败一遍——浪费流量、浪费心情。
解法是一张「失败记录表」:
// 模块级缓存:URL -> 失败次数(跨组件、跨页面生命周期)
const failCounts = new Map<string, number>();
const FAIL_THRESHOLD = 2; // 同一 URL 失败 2 次后,下次进入页面直接渲染占位、不再请求
export function handleImageError(url: string, img: HTMLImageElement) {
const count = (failCounts.get(url) ?? 0) + 1;
failCounts.set(url, count);
// 先换占位:保证卡片不塌、下方内容不跳,与是否重试无关
img.src = "/placeholder-failed.svg";
// count >= FAIL_THRESHOLD 时,列表渲染逻辑读到 failCounts 已超限,
// 会跳过原 URL 直接渲染占位——「不再重试」由这里保证,不靠本函数分支
}三个设计点:
- 键是 URL 字符串,因为图片的身份就是 URL;值是失败次数,因为业务需要区分「抖一次」和「真的死了」
- 阈值而不是一刀切:CDN 瞬时抖动占失败的相当比例,无脑重试和永不重试都是极端
- 缓存是模块级的:组件卸载不能丢——用户滑回来时,「这张图已经死过两次」的事实必须还在
进阶做法是把 failCounts 从 Map 升级成带淘汰的 LRU:信息流滑过几千张图,Map 无限增长会漏 memory。浏览器端 Map 没有容量上限,需要业务自己控制(比如只保留最近 500 条,或者只缓存「确认死亡」的 URL,成功的随手删除)。
范式二:每篇笔记的相关处理 —— Map<noteId, NoteMeta>
一篇笔记在客户端涉及的状态比想象中多:点赞没、收藏没、评论数、是否已读、相关推荐列表。这些状态的共同结构是 noteId → 状态对象,天然哈希表。
已读集合:Set<noteId>
「看过」标记最省事的实现不是给笔记对象加字段,而是一个独立 Set:
const readNotes = new Set<string>();
// 进入详情页时
readNotes.add(noteId);
// 列表渲染时
const isRead = readNotes.has(note.id); // 标题置灰的依据用 Set 而不是数组,因为「这篇看过吗」会被每一条卡片询问一次——O(n) 的 includes 在千条信息流里就是千次扫描。
互动状态本地缓存:Map<noteId, Interaction>
点赞/收藏的乐观更新需要一份本地真相:
interface Interaction {
liked: boolean;
likeCount: number;
collected: boolean;
}
const interactions = new Map<string, Interaction>();
function toggleLike(noteId: string) {
const prev = interactions.get(noteId) ?? { liked: false, likeCount: 0, collected: false };
const next = { ...prev, liked: !prev.liked, likeCount: prev.likeCount + (prev.liked ? -1 : 1) };
interactions.set(noteId, next); // 先改本地,UI 立即响应
render(noteId, next);
api.like(noteId, next.liked).catch(() => {
interactions.set(noteId, prev); // 失败回滚——本地真相显身手
render(noteId, prev);
});
}没有这份缓存,乐观更新失败后的回滚要满世界找 UI 状态;有了它,回滚就是一次 set。
相关推荐:标签反查索引
「相关笔记」的推荐可以很重(协同过滤),也可以很轻(同标签)。轻量做法是一张倒排索引:
// 构建:标签 -> 笔记 ID 列表(浏览详情页时按需构建,或首屏预构建)
const tagIndex = new Map<string, string[]>();
function buildIndex(notes: Note[]) {
for (const note of notes) {
for (const tag of note.tags) {
const list = tagIndex.get(tag) ?? [];
list.push(note.id);
tagIndex.set(tag, list);
}
}
}
// 使用:取当前笔记权重最高的标签,反查同标签笔记
function relatedNotes(current: Note, limit = 10): Note[] {
const seen = new Set<string>([current.id]); // 去重:自己不能推荐自己
const result: Note[] = [];
for (const tag of current.tags) {
for (const id of tagIndex.get(tag) ?? []) {
if (seen.has(id)) continue;
seen.add(id);
result.push(id);
if (result.length >= limit) return result;
}
}
return result;
}注意里面嵌套的 Set——哈希表在业务里从来不是单独出现的,它和别的数据结构组队:Map 管索引,Set 管去重,这是标准拍档。
范式三:Feed 流去重 —— 曝光 Set<noteId>
信息流有个隐性 KPI:同一段时间内不推重复内容。分页加载、下拉刷新、分类切换,三个入口都可能把同一篇笔记带回屏幕。
解法是会话级曝光表:
const exposed = new Set<string>();
function mergeFeed(fresh: Note[]): Note[] {
return fresh.filter((note) => {
if (exposed.has(note.id)) return false; // 已曝光过,过滤
exposed.add(note.id); // 注意:过滤的同时就要登记
return true;
});
}关键细节:登记时机是「进入列表」而不是「被用户看到」。如果用曝光监听(IntersectionObserver)来登记,快速滑过的笔记没被观测到就漏登记了;进入列表即登记,语义是「这条我有机会展示」,简单且不漏。
曝光表的边界要想清楚:刷新页面要不要清空?切分类要不要清空?通常「本次会话 + 当前分类」是合理粒度——全清会导致刷新后首页重复,永不清会导致用户抱怨「怎么老是这几条」。
为什么不用对象字面量 / 数组
三个常见疑问,一次说清:
Q:{} 不也是键值对吗,为什么用 Map?
{}的键只能是字符串/Symbol,noteId是数字时要隐式转换;Map的键可以是任意类型{}原型链上有toString等继承键,用户数据里有"constructor"这种键时会踩原型污染({}["constructor"]不报错但结果离谱)Map有size,{}要Object.keys().length- 频繁增删时
Map性能更稳({}删除键可能触发隐藏类退化)
Q:Set 和 Map 怎么选?
只关心「在不在」用 Set(已读、曝光);关心「在,而且带着数据」用 Map(互动状态、失败次数)。没有灰色地带。
Q:数组 find 不是挺直观?
数据量上三位数后就是灾难。业务列表动辄几百上千条,每次渲染每张卡片都 find 一次,是典型的「写着舒服、跑着卡」。查找频率 × 数据规模超过约 50,就该换成哈希结构。
工程细节:别让缓存变成漏内存
哈希表的业务风险从来不是「不会用」,而是「忘了大小」:
- 能淘汰的才进缓存:失败 URL、互动状态设上限(LRU 思想:超容量先淘汰最久未用)
- 会话级状态随会话销毁:曝光 Set 在页面卸载/切分类时按策略清理,别指望它自己消失
- 倒排索引按需构建:全量构建所有标签索引不如进入详情页时按当前标签现查——索引也有构建成本
小结
回看这三个范式,共同点惊人一致:
| 业务问题 | 结构 | 键 | 值 |
|---|---|---|---|
| 图片加载失败兜底 | Map | 图片 URL | 失败次数 |
| 笔记互动状态 | Map | 笔记 ID | 互动状态对象 |
| 已读 / 曝光去重 | Set | 笔记 ID | —— |
| 相关推荐索引 | Map + Set | 标签 | 笔记 ID 列表 |
哈希表在业务里的角色,本质是给「身份」一个 O(1) 的答案:这篇笔记点赞没、这张图死没死、这条内容推过没。下次写业务代码想 find 的时候,停一秒——你要找的东西,是不是早就该有一张表了。