跳到正文

· 18 分钟 · 后端 / 数据库 / 社交网站 / Cloudflare R2

社区第二期:图片、关注、通知、话题、搜索和转发

给社区加上一个社交网站最常见的六个功能。重点讲:图片在浏览器里压缩并去掉定位信息、后端怎么确认上传的真是图片;通知怎么合并、怎么撤回;原帖和转发混在一个信息流里时,分页怎么做才不重不漏。

这是 Seabloom 建站记录的第五篇。第四篇做了社区的第一期:发帖、点赞、评论、个人主页。这一篇是第二期,加了六个功能,分三批做,每批上线验证后再做下一批:

批次功能难点
一帖子带图片压缩、隐私、确认文件真的是图片、图片的权限
二关注、通知通知的合并、撤回、未读数
三话题、搜索、转发中文话题的识别、搜索的局限、混合信息流的分页

读法建议:每一节都是「需求 → 数据怎么存 → 关键代码 → 踩过的坑」。最后是面试问答。

一、帖子带图片

整体流程

  1. 01

    浏览器压缩

    选图后立刻在浏览器里重新画一遍:最长边缩到 1600 像素,导出成 WebP。一张 4000×3000 的照片,从 935 KB 变成了 101 KB。

  2. 02

    上传

    每张图单独上传到 /api/images,宽高放在网址参数里。这时图片还不属于任何帖子,只有上传的人自己看得到。

  3. 03

    后端检查

    检查大小、每天的数量、总空间,再看文件开头的几个字节,确认真的是图片,然后存进 R2。

  4. 04

    发帖

    发帖时只带图片 ID。后端确认这些图片是你传的、还没用过,再把它们挂到帖子上,并记下顺序。

  5. 05

    显示

    每次读取图片都检查它所在的帖子你能不能看到。帖子被隐藏或删除,图片也跟着看不到。

图 1:一张照片从手机到帖子里

为什么在浏览器里压缩

手机照片动辄几 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):

格式开头的字节
JPEGFF D8 FF
PNG89 50 4E 47 0D 0A 1A 0A(\x89PNG\r\n\x1a\n)
GIFGIF87a 或 GIF89a
WebPRIFF + 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 '\'

两个要注意的地方:

  1. 转义:% 和 _ 在 LIKE 里是通配符。用户搜「100%」时,要把 % 转义成普通字符,否则会匹配到所有以「100」开头的内容。这个函数和后台搜索共用同一套写法。
  2. 性能: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 个测试。社交网站的功能,单独看每一个都不难;难的是它们之间的交互:转发要发通知,通知要跟着帖子的隐藏状态变化,图片要跟着帖子的权限变化,信息流里同一篇帖子会以不同的身份出现。这一期最大的体会是:每加一个功能,都要问一遍它和已有的每个功能之间会发生什么。