· 17 分钟 · 后端 / 数据库 / 社交网站 / Cloudflare R2
社区第二期:图片、关注、通知、话题、搜索和转发
给社区加上一个社交网站最常见的六个功能。重点讲:图片在浏览器里压缩并去掉定位信息、后端怎么确认上传的真是图片;通知怎么合并、怎么撤回;原帖和转发混在一个信息流里时,分页怎么做才不重不漏。
这是 Seabloom 建站记录的第五篇。第四篇做了社区的第一期:发帖、点赞、评论、个人主页。这一篇是第二期,加了六个功能,分三批做,每批上线验证后再做下一批:
| 批次 | 功能 | 难点 |
|---|---|---|
| 一 | 帖子带图片 | 压缩、隐私、确认文件真的是图片、图片的权限 |
| 二 | 关注、通知 | 通知的合并、撤回、未读数 |
| 三 | 话题、搜索、转发 | 中文话题的识别、搜索的局限、混合信息流的分页 |
读法建议:每一节都是「需求 → 数据怎么存 → 关键代码 → 踩过的坑」。最后是面试问答。
一、帖子带图片
整体流程
01
浏览器压缩
选图后立刻在浏览器里重新画一遍:最长边缩到 1600 像素,导出成 WebP。一张 4000×3000 的照片,从 935 KB 变成了 101 KB。
02
上传
每张图单独上传到 /api/images,宽高放在网址参数里。这时图片还不属于任何帖子,只有上传的人自己看得到。
03
后端检查
检查大小、每天的数量、总空间,再看文件开头的几个字节,确认真的是图片,然后存进 R2。
04
发帖
发帖时只带图片 ID。后端确认这些图片是你传的、还没用过,再把它们挂到帖子上,并记下顺序。
05
显示
每次读取图片都检查它所在的帖子你能不能看到。帖子被隐藏或删除,图片也跟着看不到。
为什么在浏览器里压缩
手机照片动辄几 MB。如果原样上传:上传慢,存储贵,别人看帖子时也要下载几 MB。所以在上传之前就压缩:
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise((r) => canvas.toBlob(r, "image/webp", 0.85));
几个细节:
imageOrientation: "from-image":手机竖着拍的照片,像素其实是横着存的,另外记了一个「请旋转 90 度」的标记(EXIF 方向信息)。不处理的话,压缩后的图会躺倒。- WebP:同样的画质,比 JPEG 小 25%~35%。少数旧版浏览器导不出 WebP,
toBlob不会报错,而是悄悄给一个 PNG。所以导出后检查blob.type,不是 WebP 就改用 JPEG。 - 顺带保护了隐私:手机照片的 EXIF 里通常记着拍摄地点的 GPS 坐标。直接上传原图,看到图的人就可能知道你家在哪。重新画一遍再导出,这些信息就都没了。这是一个「顺便」得到的好处,但值得专门写进注释,否则以后有人为了「保留原图画质」改成直接上传,就会不知不觉引入隐私问题。
为什么不在服务器上压缩? Workers 的运行环境里没有现成的图片处理库,CPU 时间也有限制(免费版每次请求大约 10 毫秒)。Cloudflare 有付费的图片处理服务,但对个人网站来说,让每个用户的浏览器各自处理自己的图片,既免费又分散了压力。
后端怎么确认上传的真是图片
前端压缩只是为了体验,任何人都可以绕过网页直接调接口,传一个 .exe 上来,再声称它是 image/png。所以后端不能相信:
- 文件名的后缀(随便改);
- 请求头里的
Content-Type(随便写)。
能相信的是文件内容本身。几乎所有文件格式的开头几个字节都是固定的,叫文件签名(magic number):
| 格式 | 开头的字节 |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A(\x89PNG\r\n\x1a\n) |
| GIF | GIF87a 或 GIF89a |
| WebP | RIFF + 4 个字节的长度 + WEBP |
export function sniffImageType(bytes: Uint8Array) {
if (starts([0xff, 0xd8, 0xff])) return "image/jpeg";
if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
if (ascii("GIF87a") || ascii("GIF89a")) return "image/gif";
if (ascii("RIFF") && ascii("WEBP", 8)) return "image/webp";
return null; // 都不是,拒绝
}
存进数据库的类型也是后端识别出来的,而不是上传时声明的。以后读取图片时,返回的 Content-Type 就一定是一个真正的图片类型。
为什么不支持 SVG? SVG 本质上是一段 XML 代码,里面可以写 <script>。如果浏览器把它当网页打开,脚本就会在我们的域名下运行。图片格式里只有它有这个风险,所以干脆不支持。
这次和第四篇的文件空间有一个不同: 文件空间是把请求体直接「流」进 R2,不读进内存。这次要先看文件开头才能决定存不存,所以先把整张图读进内存。之所以可以这样做,是因为单张图片有 5 MB 的上限,而且在读之前就用 Content-Length 检查过了。同样是上传,根据需求选不同的做法:要检查内容就读进内存,不需要检查就流式传输。
「先传图、再发帖」带来的问题:孤儿图片
图片是选好就开始上传的(这样点「发布」时不用再等),但用户可能传了图又不发了。这些图片在 R2 里占着空间,又不属于任何帖子,叫孤儿图片。
处理方式:每次有人上传图片时,顺便找出 24 小时前上传、至今还没挂到帖子上的图片,删掉最多 20 张:
SELECT id FROM images WHERE post_id IS NULL AND created_at < ? LIMIT 20
配合一个部分索引(partial index),只给 post_id IS NULL 的行建索引,这个查询很快,而且索引本身很小(大部分图片都已经挂到帖子上了):
CREATE INDEX images_orphan ON images(created_at) WHERE post_id IS NULL;
「顺手清理」而不是「定时清理」,和第三篇清理过期会话是同一个思路:网站小,不值得多维护一个定时任务。限制每次最多 20 张,是为了不拖慢这次上传。
图片的权限跟着帖子走
图片地址是 /api/images/{id},每次读取都会检查:
- 还没发帖的图片:只有上传的人能看(发帖前的预览);
- 已经发帖的图片:和帖子同一套可见规则。帖子被站长隐藏、被作者删除、还在待审核,别人就看不到这张图。
如果图片放在 R2 的公开地址上,帖子删了,图片地址照样能打开。所以图片也要经过后端。
代价是缓存。 第三篇给所有接口加了 Cache-Control: no-store(不缓存),因为接口返回的是个人数据。但图片如果每次都重新下载,翻信息流会很慢。这里做了一个取舍:
Cache-Control: private, max-age=600
private:只允许用户自己的浏览器缓存,中间的 CDN、代理服务器不许缓存(否则一个人有权限看到的图,可能被缓存后发给没权限的人);max-age=600:缓存 10 分钟。也就是说,帖子被隐藏后,10 分钟之内看过这张图的人,还能从自己浏览器的缓存里看到。
为此,全局那个「一律不缓存」的中间件也改了:只在响应还没有设置缓存策略时才加上 no-store。
app.use(async (c, next) => {
await next();
if (!c.res.headers.has("Cache-Control")) c.header("Cache-Control", "no-store");
});
安全默认值 + 明确的例外,比反过来(默认缓存、个别接口禁止)安全得多:忘了处理的接口,默认就是安全的。
图片响应还加了 Content-Security-Policy: default-src 'none'; sandbox。正常情况下用不上,但万一将来某个检查出了漏洞,让一个网页文件混了进来,这个头也能保证它不能作为网页运行。
前端:网格和大图
- 1 张图:按原比例显示,但比例限制在 3:4(竖)到 1.91:1(横)之间,太长的图先裁一部分,点开看全图。
- 2~4 张:排成网格。3 张时第一张占左边一整列。
- 后端记了每张图的宽高。图片还没下载完时,前端就能按比例把位置占好,页面不会因为图片加载出来而跳动。
- 点开是全屏大图:左右箭头切换,Esc 或点背景关闭;打开时页面本身不跟着滚动。
上传也支持三种方式:点按钮选、把图片拖进输入框、直接粘贴(截图后 Ctrl+V 就能发)。
测试
12 个测试,比较有代表性的几个:
- 把一段
<script>当图片上传,被拒绝,而且 R2 里什么都没存; - 不能用别人传的图发帖,也不能把同一张图用在两篇帖子里;
- 帖子被隐藏后图片返回 404,站长还能看;恢复后又能看;删除后谁都看不到;
- 24 小时前的孤儿图片会被清理,已经发帖的图片不受影响;
- 只有图片、没有文字的两篇帖子,不会被「10 分钟内不能发重复内容」误伤(两篇的正文都是空字符串)。
最后这个是写代码时想到的:查重复的 SQL 是「正文相同」,而只发图的帖子正文都是空的,会被当成重复。修法是查重时排除空正文。
二、关注
数据
CREATE TABLE follows (
follower_id TEXT NOT NULL, -- 关注者
followee_id TEXT NOT NULL, -- 被关注者
created_at INTEGER NOT NULL,
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX follows_followee ON follows(followee_id);
主键 (follower_id, followee_id) 的作用和点赞一样:重复关注不会多一行,接口天然幂等。
为什么还要再建一个索引? 联合索引(主键也是一个索引)只能从最左边的列开始用:
- 「我关注了谁」:
WHERE follower_id = ?,能用主键; - 「谁关注了我」(粉丝数):
WHERE followee_id = ?,主键用不上,所以单独建一个。
这是数据库索引里最常被问到的一条规则:最左前缀原则。
「关注」信息流
只看我关注的人(加上我自己)的帖子:
WHERE p.user_id = ?1
OR p.user_id IN (SELECT followee_id FROM follows WHERE follower_id = ?1)
这种做法叫读时扩散(fan-out on read):发帖时什么都不做,每次有人看「关注」栏时再去查他关注了谁、这些人发了什么。
大型社交网站通常反过来,用写时扩散(fan-out on write):某人发帖时,就把这篇帖子的 ID 写进每个粉丝的「收件箱」,粉丝打开时直接读自己的收件箱。读得飞快,但一个有一千万粉丝的人发一条帖子,就要写一千万次。所以真实的系统往往两种混用:普通人写时扩散,大 V 读时扩散。
对我们的规模,读时扩散一条 SQL 就够了,没有理由提前做复杂。
三、通知
数据
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, -- 收通知的人
type TEXT NOT NULL, -- like / repost / comment / follow
actor_id TEXT NOT NULL, -- 做这件事的人
post_id TEXT,
comment_id TEXT,
created_at INTEGER NOT NULL,
read_at INTEGER -- 为空 = 未读
);
CREATE INDEX notifications_unread ON notifications(user_id) WHERE read_at IS NULL;
read_at 存的是「什么时候读的」,而不是一个 true/false。多存一点信息几乎不花成本,以后想统计「通知平均多久被看到」也有数据。
未读数的查询非常频繁(每个登录用户每分钟一次),所以又用了一个部分索引,只索引未读的通知。
发通知:和触发它的操作放进同一个事务
点赞的同时要发通知,这两件事放进同一个 db.batch,和审计日志是同一个道理:不会出现「赞了但没通知」或者「通知了但没赞上」。
发通知的 SQL 有点特别,不是 INSERT ... VALUES,而是 INSERT ... SELECT ... WHERE:
INSERT INTO notifications (id, user_id, type, actor_id, post_id, created_at)
SELECT ?1, p.user_id, 'like', ?2, p.id, ?3
FROM posts p
WHERE p.id = ?4
AND p.user_id != ?2 -- 自己赞自己,不通知
AND NOT EXISTS ( -- 已经通知过,不重复
SELECT 1 FROM notifications n
WHERE n.actor_id = ?2 AND n.type = 'like' AND n.post_id = p.id
)
好处是所有判断都在一条 SQL 里,WHERE 条件不满足时,SELECT 出来是空的,什么都不插入:
- 收通知的人(帖子作者)直接从
posts表里查出来,不用先查一次; - 自己对自己的操作被过滤掉;
- 重复点赞时(点赞表因为主键没有变化),通知也不会重复。
撤回
取消点赞、取消关注、删除评论时,对应的通知会被删掉。否则会出现这种情况:别人点了赞又马上取消,你收到一条「xx 赞了你的帖子」,点进去发现点赞数是 0。
还有一种情况是帖子或评论被站长隐藏了。这时不删通知(站长恢复后通知应该还在),而是在读取时过滤:
-- 只显示还有效的通知
(n.type = 'follow'
OR (p.deleted_at IS NULL AND p.status = 'visible'
AND (n.type IN ('like', 'repost')
OR (cm.deleted_at IS NULL AND cm.status = 'visible'))))
待审核的评论(命中敏感词)在提交时不发通知,等站长点「通过」时再补发。因为补发可能和别的路径撞上,评论通知也加了 NOT EXISTS 防重复。
合并
一篇帖子被 20 个人赞了,如果显示 20 条通知,真正重要的评论通知就被淹没了。所以同一篇帖子的点赞(和转发)合并成一条:「布布等 20 人赞了你的帖子」。
合并的做法:取最近 200 条原始通知,按时间从新到旧遍历,用一个 Map 按「类型 + 帖子」分组:
const groups = new Map<string, Notification>();
for (const r of rows) {
// 已经按时间从新到旧排好
const key = r.type === "like" || r.type === "repost" ? `${r.type}:${r.post_id}` : r.id;
const group = groups.get(key);
if (group) {
group.actorCount++;
if (group.actors.length < 3) group.actors.push(actor);
continue;
}
groups.set(key, { ...第一次出现的这一条 });
}
JavaScript 的 Map 会保持第一次插入的顺序,而第一次遇到的就是这一组里最新的那条,所以合并后的列表自然是按「最新动态」排序的。
未读数要和列表对得上。 铃铛上显示「3」,点进去也应该看到 3 条新的,而不是 20 条原始通知。所以未读数也在 SQL 里按同一个规则合并后再数:
SELECT COUNT(DISTINCT
CASE WHEN n.type IN ('like', 'repost') THEN n.type || ':' || n.post_id ELSE n.id END
) ...
铃铛:轮询,而不是实时推送
真正的实时推送要用 WebSocket:浏览器和服务器之间保持一条长连接,有新通知时服务器主动推过来。但 Workers 是「有请求才运行、处理完就结束」的,没法保持长连接。Cloudflare 提供了 Durable Objects 来解决这个问题,但属于付费功能。
所以用最简单的轮询:浏览器每 60 秒问一次「我有几条未读」。两个优化:
- 标签页在后台时不问(
document.visibilityState),切回来时立刻问一次; - 打开通知页、标为已读后,发一个浏览器事件,铃铛马上清零,不用等下一次轮询。
对一个个人网站的社区,一分钟的延迟完全可以接受。这也是工程里常见的权衡:先用最简单的方案满足需求,等它真的成为问题时再换。
四、话题标签
识别中文话题
export const TAG_RE = /#([\p{L}\p{N}_]{1,20})/gu;
\p{L}是 Unicode 里所有的「字母」,包括汉字。如果写成\w,只能匹配英文字母、数字和下划线,#实习就识别不出来了。- 遇到空格、标点(包括中文的逗号、句号)自然结束,所以「准备 #实习,大家」能正确识别出「实习」。
u标志开启 Unicode 模式,\p{...}才能用。
识别出的话题统一转成小写,#AI 和 #ai 算同一个话题;一篇帖子最多 5 个,防止有人用几十个话题刷热门。
存储和热门话题
CREATE TABLE post_tags (
post_id TEXT NOT NULL,
tag TEXT NOT NULL,
created_at INTEGER NOT NULL, -- 冗余了帖子的发布时间
PRIMARY KEY (post_id, tag)
);
created_at 是从帖子表冗余过来的,这样算「最近 7 天的热门话题」时,可以直接按时间过滤,再按话题分组计数:
SELECT t.tag, COUNT(*) AS count
FROM post_tags t JOIN posts p ON p.id = t.post_id
WHERE t.created_at > ? AND p.deleted_at IS NULL AND p.status = 'visible'
GROUP BY t.tag
ORDER BY count DESC
LIMIT 10
被隐藏、被删除的帖子不算,否则被站长隐藏的垃圾帖子还能把它的话题顶上热门。
前端:链接不能嵌套
话题要显示成可以点的链接。第一期里,整段正文是一个指向帖子详情的 <Link>。现在正文里面又有话题链接,就变成了链接套链接,这在 HTML 里是不允许的(浏览器会把内层链接「拆」出去,排版会乱)。
修法是正文不再是链接,改成「点击正文空白处」时跳转:
function openDetail(event) {
if ((event.target as HTMLElement).closest("a, button")) return; // 点的是话题链接或按钮,不处理
if (window.getSelection()?.toString()) return; // 在选文字,不跳转
router.push(postUrl(post.id));
}
第二个判断也很重要:用户想复制帖子里的一段文字时,拖动选中后松开鼠标也会触发 click 事件,没有这个判断就会莫名其妙跳走。
五、搜索
第一版用最简单的方式:
WHERE p.body LIKE '%关键词%' ESCAPE '\'
两个要注意的地方:
- 转义:
%和_在 LIKE 里是通配符。用户搜「100%」时,要把%转义成普通字符,否则会匹配到所有以「100」开头的内容。这个函数和后台搜索共用同一套写法。 - 性能:
LIKE '%词%'的开头是通配符,用不上索引,每次都要把整张表扫一遍。现在帖子少,毫秒级就完成了;帖子多了以后会越来越慢。
以后的方向是全文搜索。SQLite(也就是 D1)自带全文搜索模块 FTS5,原理是建一个「倒排索引」:记录每个词出现在哪些帖子里,搜索时直接查这个词对应的帖子列表。
但中文有一个特殊的难题:分词。英文单词之间有空格,天然就分好了;中文是连着写的,「机器学习」应该切成「机器 / 学习」还是整体作为一个词?FTS5 默认的分词器会把一整串连续的汉字当成一个词,搜「学习」就搜不到「机器学习」。解决办法有两种:
- trigram(三元组)分词:把文本切成每 3 个字一组,「机器学习」变成「机器学」「器学习」。不需要词典,但搜两个字的词时不够用。
- 在写入前用中文分词库切好词,再交给 FTS5。效果好,但要在 Workers 里跑一个分词库。
这个问题等帖子数量真正需要时再解决,到时候单独写一篇。
六、转发
数据
CREATE TABLE reposts (
user_id TEXT NOT NULL,
post_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, post_id)
);
ALTER TABLE posts ADD COLUMN repost_count INTEGER NOT NULL DEFAULT 0;
转发和点赞的实现几乎一模一样:主键防重复、在同一个事务里重新计数、通知作者、取消时撤回。第四篇讲点赞时总结的那套方法,在这里直接复用了。
难点:原帖和转发混在一个信息流里
在个人主页和「关注」栏里,要按时间显示:
- 这个人(或我关注的人)发的帖子,按发帖时间排;
- 这个人(或我关注的人)转发的帖子,按转发时间排,并显示「xx 转发了」。
一篇三天前的帖子,今天被转发了,就应该出现在今天的位置。
这意味着信息流里的每一条,不再是「一篇帖子」,而是「一个条目」(entry):它指向一篇帖子,但有自己的排序时间和自己的 ID。用 SQL 的 UNION ALL 把两种来源拼起来:
WITH entries AS (
-- 原帖:排序时间 = 发帖时间,条目 ID = 帖子 ID
SELECT p.id AS post_id, NULL AS reposter_id, p.created_at AS sort_at, p.id AS entry_id
FROM posts p WHERE ...
UNION ALL
-- 转发:排序时间 = 转发时间,条目 ID = 'r-转发人-帖子'
SELECT r.post_id, r.user_id, r.created_at, 'r-' || r.user_id || '-' || r.post_id
FROM reposts r WHERE ...
)
SELECT 帖子的所有字段, e.reposter_id, e.sort_at, e.entry_id
FROM entries e JOIN posts p ON p.id = e.post_id ...
ORDER BY e.sort_at DESC, e.entry_id DESC
UNION ALL 和 UNION 的区别:UNION 会去重(要额外排序比较,更慢),UNION ALL 不去重。这里两部分本来就不会重复(条目 ID 不同),所以用更快的 UNION ALL。
分页的游标也要换
第二篇讲过游标分页:用「上一页最后一条的时间和 ID」作为下一页的起点。现在排序依据变成了条目的排序时间和条目的 ID,游标也要跟着换:
const nextCursor = `${last.sort_at}_${last.entry_id}`;
如果还用帖子的发布时间和帖子 ID 做游标,转发的条目就会被放错位置,翻页时出现重复或遗漏。测试里专门造了 20 篇原帖、20 篇别人的帖子、20 次转发,交错排列,翻两页后检查 40 个条目一个不多、一个不少。
前端也要按条目区分
同一篇帖子可能在列表里出现两次(原帖一次,被转发一次),React 列表的 key 不能再用帖子 ID,要用 entryId,否则 React 会认为两个元素是同一个,渲染出错。
点赞时,两个条目指向的是同一篇帖子,点赞数应该一起变:
setPosts((prev) =>
prev.map((p) => (p.id === id ? { ...next, entryId: p.entryId, repostedBy: p.repostedBy } : p)),
);
按帖子 ID 找到所有相关条目,更新帖子的数据,但保留每个条目自己的 entryId 和「谁转发的」。
为什么「全部」栏不显示转发
「全部」栏是所有人的帖子。如果也显示转发,一篇热门帖子被 30 个人转发,就会在列表里出现 31 次。转发的意义是「推荐给关注我的人」,所以只在转发人的主页和关注者的「关注」栏里出现。
七、踩过的坑
本地开发服务器突然没有样式了
改完代码重新构建后,本地打开页面,样式全没了,只剩一朵黑色的大花(Logo 的 SVG 没有了样式约束)。查网络请求,CSS 文件返回 500。
原因是 next build 会删掉整个 out/ 目录再重新生成,而本地的 wrangler dev 正在监视这个目录。Windows 上目录被删除后,监视器报错 EPERM 并自动关闭,之后它还在用旧的文件清单,新构建出来的 CSS 文件名(带哈希)它不认识。
这只影响本地开发,线上部署每次都是全新的环境,不会有这个问题。重启本地服务器就好了。判断一个问题是「本地环境的问题」还是「代码的问题」,是调试时第一步要想清楚的。
正则表达式里的反斜杠被「吃」掉了
有几处代码是用脚本批量插入的,其中的 \p{L}、LIKE 转义用的 '\\' 经过了好几层字符串处理(Python 字符串 → 文件 → TypeScript 字符串 → SQL 字符串),每一层都会把 \\ 变成 \。结果 SQL 里的 ESCAPE '\\' 变成了 ESCAPE '\',语法错误。
教训:带反斜杠的代码,写完一定要回头看一眼最终文件里的样子,不要相信「我写的时候是对的」。
八、面试怎么讲
一分钟介绍
社区第二期加了图片、关注、通知、话题、搜索和转发。图片在浏览器里压缩成 WebP,顺带去掉 EXIF 里的定位信息;后端不信后缀和 Content-Type,按文件签名识别类型。图片的读取权限跟着帖子走,帖子被隐藏图片也看不到,缓存用 private 加短时间来平衡。通知用 INSERT ... SELECT ... WHERE NOT EXISTS 一条 SQL 处理收件人、自我过滤和去重,和触发操作在同一个事务里;点赞通知按帖子合并,未读数用同样的规则计数。转发和原帖用 UNION ALL 合成统一的条目,按条目的排序时间和 ID 做游标分页。
可能被问到的问题
Q:怎么判断用户上传的文件真的是图片? A:不能信后缀名和请求头里的 Content-Type,两者都由客户端决定。要看文件内容开头的签名字节,比如 JPEG 是 FF D8 FF,PNG 是 89 50 4E 47。识别出的类型存下来,读取时用它作为 Content-Type。更严格的做法是在服务器上用图片库完整解码一遍,再重新编码,这样即使文件头伪造了也会解码失败;我的场景里,前端已经重新编码过,后端只检查签名,并加了 CSP sandbox 和 nosniff 兜底。SVG 因为能包含脚本,直接不支持。
Q:通知系统怎么设计?
A:一张通知表,记录收件人、类型、触发者、关联的帖子或评论、时间和已读时间。发通知和触发它的操作在同一个事务里。用 INSERT ... SELECT ... WHERE 在一条 SQL 里查出收件人,并排除自己对自己的操作和重复通知。撤销操作时同步删除通知;内容被隐藏时读取时过滤,因为隐藏可能被恢复。列表把同一篇帖子的点赞合并显示,未读数用相同的分组规则计数。推送方式用轮询,后台标签页不轮询;规模大了会换成 WebSocket 或者推送服务。
Q:关注的人的信息流怎么实现?大规模时怎么办? A:我用的是读时扩散:查询时用子查询找出我关注的人,再取他们的帖子。实现简单,写入没有额外成本,但关注的人多、帖子多时查询会变慢。大规模系统常用写时扩散:发帖时把帖子 ID 推到每个粉丝的收件箱,读的时候直接读收件箱;但粉丝特别多的账号写入成本太高,所以通常混合使用,大 V 的帖子在读取时再合并进来。
Q:混合了原帖和转发的列表,分页怎么做? A:把两种来源 UNION ALL 成统一的条目,每个条目有自己的排序时间(发帖时间或转发时间)和唯一的条目 ID,按这两个字段排序,游标也用这两个字段。如果游标用帖子自己的时间和 ID,转发的条目位置不对,翻页就会重复或遗漏。前端列表的 key 也要用条目 ID,因为同一篇帖子可能出现两次。
Q:联合索引的最左前缀原则是什么?
A:联合索引 (a, b) 按 a 排序、a 相同再按 b 排序,所以能加速 WHERE a = ? 和 WHERE a = ? AND b = ?,但单独的 WHERE b = ? 用不上,因为 b 在整个索引里不是有序的。我的关注表主键是 (关注者, 被关注者),查「我关注了谁」能用主键,查「谁关注了我」要单独给被关注者建索引。
Q:中文全文搜索有什么难点? A:主要是分词。英文按空格天然分词,中文是连续的,数据库默认的分词器会把一整段汉字当成一个词,搜词组的一部分就搜不到。常见解决方案是 n-gram(比如 trigram,按固定长度切)或者用中文分词库预先切词,再建倒排索引。规模更大时用 Elasticsearch 这类专门的搜索引擎,配合中文分词插件。
Q:缓存和权限冲突时怎么取舍?
A:需要权限检查的资源不能被共享缓存(CDN)缓存,否则可能把有权限的人看到的内容发给没权限的人,所以用 Cache-Control: private。浏览器本地缓存可以,但缓存时间就是「权限变化后的最长延迟」,我设了 10 分钟。需要立即生效的场景,可以用带签名、短期有效的 URL,或者在资源 URL 里带版本号,权限变化时换一个 URL。
九、下一步
- 全文搜索:等帖子多了,用 FTS5 加中文分词。
- 引用转发:转发时附上自己的一段话。
- 回复评论:评论目前只有一层。
- 实时通知:用 Durable Objects 或 Server-Sent Events 替代轮询。
- 数据库搬到亚太区:社区越来越像主功能了,国内访问的延迟越来越值得解决。
现在一共 123 个测试。社交网站的功能,单独看每一个都不难;难的是它们之间的交互:转发要发通知,通知要跟着帖子的隐藏状态变化,图片要跟着帖子的权限变化,信息流里同一篇帖子会以不同的身份出现。这一期最大的体会是:每加一个功能,都要问一遍它和已有的每个功能之间会发生什么。