社区后端从零建表:MySQL表结构与索引优化实战
准备自己动手写一套带社区功能的后端,比如“用户、文章、互动”这一套体系,很多人第一步就会卡在数据库表设计上:要么表和表之间耦合得乱七八糟,要么数据量稍微上来一点,一条列表页查询就慢到让人怀疑人生。我最近刚把一个社区项目的后端从零撸完,把 MySQL 表结构和优化思路完整落地了一套,今天把这些沉淀整理出来。
这篇文章的核心是一条主线:先把“用户—文章—互动”这几张主表设计清楚,再谈索引、缓存、计数一致性这些优化手段。适合正在做前后端分离项目、准备自己写后端的同学,也对那些想从 CRUD 顺手进阶到“能扛一点流量”的开发者有参考价值。如果你是从前端转后端,或者刚接第一个后端任务,这篇文章可以直接照着建表,不需要绕弯。
1. 先拆需求再建表:社区核心数据模型的边界划分
建表最容易犯的错,就是拿到需求直接开写 SQL,写完之后发现某个字段不知道该放哪张表、某个统计值怎么更新都别扭。我在动手之前先把社区系统的数据拆成了四大类:人、内容、关系、动作。人就是用户;内容是文章和评论;关系是关注;动作是点赞、收藏这类互动记录。分类清楚之后,每张表的职责就非常明确:用户表管账号和基础资料,文章表管内容资产,评论和互动关系各自独立成表,不互相塞字段。
这个划分背后有一个很实际的理由:游戏规则里“谁产生了数据”“数据和谁相关”“这个数据会怎样变化”三者要能对上。比如一篇文章的点赞数,看起来可以放进文章表里作为一个 count 字段,但点赞这个动作本身是用户产生的,还需要记录谁在什么时候点了赞,所以必须有一张独立的互动表,文章表里那个 count 只是冗余出来的统计值,不是数据源头。
社区系统往后大概率还会加新玩法,比如文章可以投进多个合集、用户可以屏蔽某些人、评论可以被打赏。如果一开始就把字段塞进某一张大表里,后面每次加业务都要改表结构,牵一发动全身。把边界划清楚,业务扩展就变成了“再加一张关联表”而不是“重构一张旧表”。
1.1 用户表:账号、资料与统计冗余怎么共存
用户表是所有表的起点,文章要挂作者、评论要挂发言人、点赞要挂操作人,几乎每张表都有 user_id。用户表设计的核心矛盾是:账号相关字段要求稳定、唯一、安全,资料相关字段要求可以灵活修改,还有几个统计字段牵扯到全站其他模块的写入频率。
这里有几个关键选择值得展开说。
第一,密码字段永远不要存明文,也不要存可逆加密串。password_hash 我用 varchar(255),存的是 bcrypt 或 argon2 生成的哈希串,长度远大于普通口令,所以给足空间。登录校验时拿用户输入的口令去比对哈希,而不是比对字符串本身。
第二,username 和 email 都建了唯一索引。唯一索引在这里不只是查询加速,更是数据约束,它从数据库层面保证同一个登录名不会被注册两次。没有这个唯一约束,并发注册时即使代码里先查后插,也可能同时插入两条相同用户名,线上出问题极难排查。
第三,follower_count、following_count、article_count 这三个统计字段是反范式的典型例子。正常范式下,粉丝数应该对关注表做 COUNT(*) 才算出来,但社区场景里用户主页每次打开都要展示粉丝数,如果每次都实时 COUNT,关注表稍微大一点就会拖慢整个页面。把统计值直接冗余到用户表,牺牲一点一致性,换来查询性能的极大提升。代价是关注、取关、发文、删文章时,必须额外更新这几个字段,这个“写放大”是值得的。
用户表还有一个容易踩的坑:软删除和唯一索引冲突。假设用户注销账号,你不想物理删除,因为他的文章、评论还需要保留作者信息。如果给 username 加了一个 deleted 标记,下次新用户注册同一个用户名时,唯一索引会直接报错。我落地时给了一个可行方案:注销时不改 username,而是把账号状态置为禁用;如果产品要求用户名必须释放,就在注销时把 username 改成一个不可读的唯一值,比如原始用户名 + 下划线 + 用户ID,这样既保证唯一,又不影响新用户注册。
1.2 文章表:内容字段、状态流转与列表索引布局
文章表是社区的内容核心,承载标题、摘要、正文、封面、分类、标签,还要挂状态、计数和时间。文章表最大的复杂度不在字段数量,而在“文章状态”和“列表查询”这两种业务逻辑同时叠加。
正文我用的是 mediumtext。社区文章一般在几千到几万字之间,mediumtext 上限是 16MB,足够常规项目使用。如果文章量级到百万篇级别,可以考虑把正文挪到对象存储,MySQL 里只保留一个内容地址,但那属于另一个优化层次,在单库阶段直接把正文存 MySQL 完全可行,备份和查询都方便。
文章状态我设计成 0 草稿、1 发布、2 审核中、3 下架、4 删除。五种状态用 tinyint 表示,比直接用字符串省空间,也比布尔值有扩展性。文章列表页基本只展示 status = 1 的数据,所以 idx_status_created 这个索引对“首页最新文章列表”非常关键。分类页会同时过滤分类和状态,所以 idx_category_status_created 把分类放在最左边,匹配“某分类下的已发布文章按时间排序”这个高频查询。
这里顺带说一个新手经常忽略的点:索引的字段顺序不能随意调整。(category_id, status, created_at) 和 (status, category_id, created_at) 面对的查询场景完全不同。MySQL 索引遵循最左前缀原则,查询条件里没有分类、只有状态时,走第一个索引就完全失效;反过来,查询只按状态过滤时,第二个索引能直接用上。这就是为什么我同时建了两个看似相近的索引,它们各自服务不同的查询路线。
tags 我用逗号分隔的字符串存储,没有单独建标签表。对中小型社区来说,标签只用来展示和粗略筛选,这个方案够用也简单。如果以后要做标签聚合统计或按标签精确分页,再拆一张文章标签关联表也不迟,数据迁移成本可控。
2. 互动体系是主战场:点赞、收藏、关注与评论的表设计取舍
用户和文章是内容底座,真正让社区活起来的是互动。互动数据的特点是写入频繁、增长极快、每个用户对同一对象的动作具有唯一性。互动设计最忌讳的是把“谁对什么东西做了什么”写进业务表里当列表字段,比如文章表里加一个 like_user_ids 的 JSON 字段,或者用户表里加一个 follow_user_ids 的字段。这种设计在查询“我有没有点赞过这篇文章”时,要把整个列表取出来遍历,数据量一大就必崩。
关系型数据库处理这种一对一、一对多的“边”关系,标准做法就是独立关系表。每张互动表都围绕一个核心问题设计:这条记录的唯一性由什么决定?
2.1 点赞与收藏:同一套模式,两个独立表
点赞和收藏的业务语义不一样:点赞是轻量动作,取消后没有痕迹;收藏带有个人资料库属性,用户会反复查看自己收藏了什么。所以它们各自单独建表,但表结构高度相似。
uk_user_target 唯一索引是这个设计的灵魂。它保证同一个用户对同一篇文章永远不会存在两条点赞记录。没有这个唯一索引,用户连续点击两次点赞按钮,代码里又没有做好防重,数据库就会写入两条一模一样的记录,页面上的点赞数直接翻倍,而且这种脏数据极难清洗。
target_type 字段给了互动体系横向扩展的能力。今天点赞对象是文章和评论,明天如果要点赞用户的某个动态,只需要在业务层约定一个新的类型值,不需要新建表。建表时把 target_type 和 target_id 一起放进唯一索引,让“用户—对象类型—对象ID”三元组天然唯一。
点赞和收藏的“取消”动作,我用 status 字段做成软更新,而不是物理删除。原因有二:一是保留历史互动记录,做推荐或者数据复盘时这些数据弥足珍贵;二是避免唯一索引在和物理删除并行时出现边界问题。用户反复点赞、取消、再点赞,每次更新同一行记录即可,行数不会膨胀,查询“我赞过哪些文章”只需要 WHERE user_id = ? AND status = 1。
收藏表的结构几乎一样,只是字段名把 like 换成 collect,target_type 当前只需要支持文章。我在实际项目里给收藏表加了一个可选的 folder_id,让用户可以把收藏内容分组,建表的同时预留这个字段,避免后续要加时大规模迁移。
2.2 关注关系:有向边的存储与互关判断
关注关系是一张典型的有向图边表:A 关注 B,和 B 关注 A 是两条完全不同的记录。很多人第一次设计关注表时会把字段命名为 user_id 和 follow_user_id,字段一多就分不清方向。我建议用 follower_id(关注者)和 followee_id(被关注者)这种语义明确的命名,看一眼 SQL 就知道数据流向。
唯一索引 uk_follower_followee 保证同一条关注关系只能存在一次。用户 A 反复点击“关注用户 B”,数据库层面直接拦截重复记录。两个二级索引分别服务两种查询:follower_id 前缀走唯一索引,查询“我关注了谁”;idx_followee 单独服务“谁关注了我”,也就是粉丝列表查询。
互相关注判断在社区业务里很常用,比如私信、好友标识。这条判断不要写成两条 SQL 分别查,一条 SQL 就能搞定:
如果要同时判断“我是否关注了你”和“你是否关注了我”,一次性查出两条记录更高效:
关注和取关时,除了写互动表,还要同步更新用户表里的 follower_count 和 following_count。这两个统计字段在用户表上的更新逻辑要放在同一个事务里,否则可能出现“关注关系已经建立,但粉丝数没有加一”的数据不一致。
关注关系还有一个隐藏问题:用户量大之后,百万级粉丝的大 V 的粉丝列表查询会很吃力。因为 idx_followee 索引里按 followee_id 过滤出来的数据可能达到几十万条,分页性能是个大难题。这个问题放到后面的优化部分一并说,表层面能做的就是把索引建好,把唯一约束立住。
2.3 评论的树形结构:root_id 与 parent_id 组合
评论是互动体系里最容易被设计过度、也最容易被设计不足的模块。一篇文章的评论区有两层诉求:顶层评论要按时间流分页展示,回复要挂在对应评论下面。很多项目一开始只设计了 parent_id,表示直接父评论,结果取“某评论下的所有回复”时,用的却是 WHERE parent_id = ?,只能拿到一级子回复。等产品经理说“我要看到这条评论的所有楼中楼”,就傻眼了。
我用 parent_id 加 root_id 的组合来解决这个问题。
顶级评论的 parent_id 和 root_id 都是 0。二级评论的 parent_id 指向它的直接父评论,root_id 指向这条评论所属的顶级评论。比如用户 A 发了一条顶级评论 ID=100,用户 B 回复 A 产生评论 ID=101,那 101 的 parent_id=100,root_id=100;用户 C 又回复 B 产生评论 ID=102,那 102 的 parent_id=101,root_id=100。
这个结构的精妙之处在于查询评论区首页时,可以直接按顶级评论分页:
当用户展开某条顶级评论下的全部楼中楼时,直接查:
idx_article_root_created 这个联合索引正好覆盖这两类查询。第一类查询里 root_id = 0 能走索引前缀;第二类查询里 article_id + root_id 定位到某个根评论下的所有回复,再按 created_at 排序,索引天然有序,不需要额外 filesort。
评论表同样做了软删除而不是物理删除。被删除的评论内容保留在库里,只是 status 置 0,前端展示时显示“该评论已删除”。这样做的意义是保留上下文的完整性,不然一条回复的父评论被删了,整个对话就断了线。
3. 从表到快:索引设计与查询优化的落地手段
表结构设计得再合理,查询一慢,用户一样会弃坑。社区系统典型的高频查询集中在几个固定模式:最新文章列表、分类文章列表、文章详情、我的点赞列表、用户粉丝列表。优化这些慢查询,核心武器是索引,但索引不是建得越多越好。我落地时遵循三个原则:覆盖高频查询、避免冗余索引、能用索引排序就不要 filesort。
3.1 组合索引覆盖高频查询:字段顺序决定一切
在文章表里,我建了 idx_status_created(status, created_at)。“首页最新文章流”是这个索引最直接的服务对象:
这条查询的过滤条件是 status = 1,排序条件是 created_at DESC。由于 B+ 树索引是先按 status 再按 created_at 排列的,MySQL 可以直接沿着索引找到所有 status = 1 的行,而且这些行已经天然按 created_at 排好序,不需要额外的排序操作。
分类页则是另一条路径:
idx_category_status_created(category_id, status, created_at) 的组合索引与这条查询完全匹配。查询条件里先过滤分类,再过滤状态,最后按时间排序,索引的字段顺序就是查询条件出现的顺序。
但如果有人把组合索引建成 (created_at, category_id, status),排序字段放在了最前面,那这条查询走索引时,category_id 和 status 的过滤条件在索引后面,等于索引无法快速定位范围,性能立刻下降一个档次。这个细节我在面试别人时经常拿出来问,能答清楚的人说明是真的理解索引结构,而不是背概念。
3.2 explain 排障:一次慢查询定位的完整过程
有个真实案例可以完整还原慢查询排查流程。社区详情页要展示作者信息,新手通常会这么写:
这个 SQL 在文章数 10 万、用户数 5 万的时候,首页响应时间还能压在 100 毫秒内。等文章涨到 50 万,响应时间开始飙升到 800 毫秒。用 EXPLAIN 看执行计划,会发现问题出在 join 的驱动方式和回表次数上。
文章表虽然走了 idx_status_created 索引,但由于使用 SELECT a.*,每行数据都要回到主键聚簇索引取整行字段,20 条结果意味着 20 次回表;接着还要拿这 20 个 author_id 去 user 表逐行匹配。虽然只有 20 次,但 user 表的 nickname 和 avatar_url 并不在某个二级索引的叶子节点上,每次匹配都要回表读用户行。
我的改造方案分两步走。第一步,文章表冗余作者昵称和头像字段,把 join 彻底干掉:
文章作者改昵称或换头像时,再批量更新文章表里冗余的字段。社区场景下用户改名频率很低,这个写放大的成本可以接受,带来的收益是整个列表页少了一次 join,响应时间直线下降。第二步,列表页只查必要字段,不要 SELECT *。查询结果只用到 6 个字段,就只查这 6 个字段,这样二级索引如果恰好覆盖这些字段,连回表都能省掉。
我还遇到过另一个典型问题:给文章表加了 (author_id, status, created_at) 索引后,发现“某作者的文章列表”查询还是慢。用 EXPLAIN 看,发现 Extra 列里出现了 Using filesort。原因是原索引是 (author_id, status),排序字段 created_at 不在索引里,MySQL 必须先把符合条件的数据找出来,再单独排序。补上 created_at 到索引尾部后,排序直接消掉了。很多人对 Using filesort 不敏感,其实这个标志对大数据量排序查询是死刑判决,看到它就要条件反射去调整索引。
3.3 别碰的几个雷区:隐式转换、深分页与冗余索引
索引建好了,不等于查询一定快。有三个雷区我几乎每次排查慢查询都能碰到。
第一个是隐式类型转换。文章表 article_id 是 bigint 类型,代码里把分页参数 pageSize 从 HTTP 请求里拿出来时是字符串,拼接 SQL 时如果没有做强类型转换,MySQL 可能会把索引列转成字符串再比较,索引直接失效。更隐蔽的场景是手机号字段用 varchar 存储,查询时传入整数 13800138000,MySQL 会把 varchar 列做数值转换再比对,同样走不了索引。判断方法很简单:查询条件字段的类型和传入参数的类型必须完全一致。
第二个是深分页。社区后台管理要翻到第 5000 页,SQL 是:
MySQL 实际上会把前 100000 行全部查出来再丢弃,只返回最后 20 行,效率极低。优化方案是把“翻页”改成“滚动加载”模式:
这个写法让每页查询直接通过索引定位到上一页最后一条记录的位置,跳过前面所有数据。代价是失去了“跳转到任意页”的能力,但对社区信息流场景来说,用户根本不关心第 345 页长什么样,滚动加载反而更符合使用习惯。
第三个是冗余索引。(status, created_at) 和 (status, created_at, id) 这两个索引,从查询角度看后者几乎完全覆盖前者,前者就没有存在必要。冗余索引浪费磁盘空间,更致命的是每次插入、更新、删除都要维护多个索引树,写入性能被拖累。我建索引的最终版原则很简单:先通过慢查询日志定位真正慢的 SQL,再针对性建索引,绝不多建一个“看起来有用”的索引。
4. 高流量下社区读多写少的优化策略
社区系统的流量模型几乎都是读多写少,首页可能一分钟被刷几千次,但真正发文、点赞、关注的写入量比读少一个量级。数据库优化的核心思路,就是让读请求尽量不压到 MySQL 上,把有限的数据库连接留给写入和强一致性的查询。
4.1 缓存谁、缓存多久:MySQL 与 Redis 的分工边界
文章详情页是社区系统访问压力最大的接口之一。同一篇热门文章可能被几十万用户反复查看,如果每次都查 MySQL,数据库压力无法想象。我在文章详情接口上做了两级缓存:文章基本信息缓存到 Redis,计数类数据直接走缓存。
接入缓存的查询流程是标准的 Cache Aside 模式:先查 Redis,命中就直接返回;没命中就查 MySQL,把结果回填到 Redis,同时设置过期时间。文章详情这类数据一致性要求不那么极端的场景,缓存过期时间设置在 30 分钟到 1 小时之间,作者自己修改文章后主动删除缓存,下次查询自动回填最新数据。
这里要特别讲一下缓存更新时间点。很多人习惯“先更新数据库,再更新缓存”,这个做法在并发场景下问题很大。两个请求同时修改文章,一个把数据库改成 A,一个改成 B,最后回写缓存的顺序可能和数据库更新顺序不一致,缓存里存了旧值。所以我的原则是“更新数据库后直接删除缓存”,而不是更新缓存。下次查询缓存未命中,自动拉取最新数据回填,既简单又不会出现双写不一致。
缓存穿透、击穿、雪崩这三个问题,新手一听头大,但处理起来逻辑并不复杂。缓存穿透是查询一个不存在的文章 ID,每次缓存都不命中,请求全部打到底层 MySQL。解决办法是查不到数据时,也在 Redis 里写一个空值并设置较短过期时间,比如 2 分钟,后续相同的无效请求直接命中空值缓存,不再打到数据库。缓存击穿是某个热点 key 在过期瞬间,大量请求同时打到数据库。解决办法是用互斥锁,只允许一个请求去查数据库并回填缓存,其他请求等待锁释放后直接读缓存。缓存雪崩是大量 key 在同一时间过期,用设置过期时间时增加随机数来打散过期点,比如基础过期时间 3600 秒,再随机加 0 到 300 秒。
4.2 计数准确性:从直接 UPDATE 到异步最终一致
文章详情页需要展示浏览量、点赞数、收藏数、评论数。最粗暴的做法是每次访问都执行:
在低流量阶段这个方案完全可行,因为 UPDATE 是原子操作,不会出现计数丢失。但流量上来之后,这个语句会成为热点行更新的焦点:同一篇文章被大量用户同时浏览,InnoDB 必须对这行的锁进行串行调度,MySQL 的写入吞吐瞬间见底。
我给点赞、收藏、浏览量这类计数场景做的优化是分两步走。第一步是计数先到 Redis,用 INCR 命令累加,Redis 单线程模型处理这种计数累加远超 MySQL 行锁的效率。第二步是通过一个定时任务,周期性把 Redis 里的增量同步到 MySQL。这个方案有一个需要接受的前提:某个瞬间 MySQL 里的计数和 Redis 里的计数可能不一致,这在社区场景完全能接受,用户看到的是一个“足够新的数字”,而不是一个强一致的快照。
Redis 与 MySQL 的计数同步,我在落地时选择了一个最容易把控的方式:用一个独立的计数对象存储 key,比如 article:view_count:10086,定时任务每 5 分钟把所有文章的浏览量增量取出,执行一条批量更新 SQL:
注意这里用 view_count = view_count + ? 而不是直接赋值,即使定时任务处理过程中有新的浏览量写入,也不会被覆盖丢失。
点赞数的更新我没走异步,而是保持同步事务更新。原因在于点赞动作需要立刻反馈给用户“我赞过了”,而且需要保证 user_like 表里有记录,同时 article.like_count 加一,这个事务一致性不能妥协。点赞的写入量虽然比浏览小得多,但在微博热点那种场景下,某个爆款文章也可能被瞬间点赞上万次,这时候同样可以把计数搬到 Redis,但“是否点过赞”的查询结果仍然必须从 MySQL 主库读取,确保准确。
4.3 告别手写 join:列表页性能的最终解法
前面提到文章表冗余作者昵称和头像,本质上就是把 join 从查询链路里移除。这个思路可以推广到评论列表、粉丝列表、点赞列表。核心原则是:列表页需要的展示字段,尽量提前冗余到列表表本身,或者用一次批量查询内存组装。
评论列表是一个很典型的案例。评论表里只有 user_id,前端要展示评论者的昵称、头像。如果直接在 SQL 里 join user 表,评论列表每页 20 条,就要对 user 表做 20 次回表查询。我的做法是先查评论列表本身:
拿到 20 条评论后,把里面的 user_id 收集成一个数组,再用一条查询批量查出用户信息:
然后在内存里把评论和用户信息组装起来。这样数据库只执行了两条简单查询,每一条都能走索引,比一条复杂的 join 要高效得多。这种“先查主表、再批量查关联数据、内存组装”的模式,是社区后端在高流量下的标准解法。
关注关系里的粉丝列表同样如此。查询粉丝列表时,先用 idx_followee 索引查出 follower_id 列表,再批量查 user 表补齐头像和昵称。两个查询都是索引覆盖,性能非常稳定。
4.4 分库分表不是银弹:先想清楚自己的瓶颈
很多项目一聊到优化就在想分库分表,我见过太多数据库只有 10 万条数据就开始上分库分表的案例,中间件带来的复杂度远超收益。分库分表解决的是单库单表的容量上限和写入吞吐瓶颈,但绝大多数社区项目,单表千万级数据配合合理索引,在 MySQL 上都能跑得很稳。
先检查自己的慢查询日志,先看缓存命中率,再判断是不是索引设计不合理,最后才考虑分库分表。过早引入分库分表,意味着所有的 join 查询跨库、事务跨节点、分页跨库合并,这些复杂度远比多买一台物理机跑主从要麻烦得多。我写这个社区后端的目标是“让它在单库单表上稳定支撑几十万用户”,在这个量级下把索引、缓存、冗余字段这三板斧用到位,数据库完全不会是瓶颈。
我还做了一件事:为文章表按创建时间做了按月分区的预案,但没有真正启用。分区可以把历史数据分开存放,让最近的活跃数据集中在少量的物理分区里,查询 Io 大幅下降。分区和分表的核心区别在于:对应用层完全透明,代码不需要做任何改动。如果未来数据量继续增长,这可以作为从单表到分库分表之间的平滑过渡方案。
5. 互动热点的隐藏问题:粉丝列表与大 V 场景
前面关注关系表里已经提到,粉丝列表查询在用户量大后会成为热点。这里展开细说,因为我在实际跑数据时确实踩到了坑。
假设一个大 V 有 50 万粉丝,用户打开他的粉丝列表,SQL 是:
这条 SQL 的问题在于 idx_followee 索引虽然能快速找到这位大 V 的 50 万条粉丝关系记录,但 ORDER BY f.created_at DESC LIMIT 20 需要先把 50 万条记录按 created_at 排序,然后取前 20 条,这个排序的开销在大数据量下非常可观。更麻烦的是,如果要按粉丝数排名,或者按活跃度排序,索引根本无法覆盖。
我的落地解法是把“粉丝列表”这类超大集合的查询前置到 Redis 的 zset 里维护。每个用户对应一个 zset,key 是 user:followers:{userId},score 是关注时间戳,member 是 follower_id。用户每次新增一个粉丝,就往这个 zset 里 ZADD 一次;取关时 ZREM。查询粉丝列表直接 ZREVRANGE,天然按时间倒序,性能和热点问题一起解决。
但这里有一个所有缓存方案都会遇到的坑:缓存和数据库的一致性。如果 zset 数据和 user_follow 表不一致,粉丝列表可能漏人或者多出已取关的人。我的经验是:把 zset 当作纯读缓存,写入永远以 MySQL 为准。关注动作发生时,事务里写 MySQL,同时写 Redis;如果 Redis 写入失败,不做强一致补偿,而是等这个 zset 过期后从 MySQL 全量重建。用户看到粉丝列表短暂少一个人,远比看到数据库被拖垮要好。
大 V 场景的另一个优化点是粉丝数的更新。以前每次加粉丝都直接:
一个百万粉大 V 每次涨粉都会更新这行热点数据,成为全局写入瓶颈。我在 Redis 里单独维护 user:follower_count:{userId},粉丝数变动时只更新 Redis,定时任务批量刷回 MySQL。用户主页展示粉丝数时优先读 Redis,未命中才读 MySQL。这个方案在整个社区系统上线后表现非常稳定,热点行更新的问题被彻底绕开。
6. 建表之后别急着上线:事务与性能的收尾检查
表结构和索引都设计完了,还要做几项收尾工作,很多项目上线后出问题,都是在这些细节上埋了雷。
第一件事是检查所有需要事务的接口是否真的包了事务。用户在文章下发布评论,业务动作包括:向 comment 表插入评论、文章表的 comment_count 加一、可能还要给文章作者发一条站内通知。这三个动作必须放在同一个事务里,任何一个失败都要全部回滚,否则会出现评论存在但计数不更新,或者通知发了但评论没插入的脏数据。在 Spring 工程里用 @Transactional 注解是最常见的做法,但要注意事务方法不能在同类内部直接调用,否则注解会失效,这个坑防不胜防。
第二件事是给所有的 article_id、user_id 等外键逻辑字段加上索引,即使不建物理外键也要建普通索引。物理外键在互联网高并发场景下一般不用,因为外键强制约束会影响插入和删除的性能,同时让死锁的概率变高。但如果不建普通索引,删除文章时校验“有没有人评论过这篇文章”会变成全表扫描,慢到不可接受。
第三件事是批量更新统计字段时,要使用原子累加而不是先查后写。上面同步策略里写到的:
这个写法天然安全,因为整个操作在数据库层面是原子的。如果写成“先 select 查出来,在代码里加一,再 update 覆盖”,两个请求同时执行就会互相覆盖,计数直接少一次。
第四件事是关注时间字段的时区问题。created_at 用 datetime 存储,应用层统一使用 UTC,展示层再转本地时区。MySQL 的 CURRENT_TIMESTAMP 默认值让插入记录时不需要手动维护时间字段,但要注意 DATETIME 和 TIMESTAMP 的差异:TIMESTAMP 范围只能到 2038 年,使用 2038 年之后的数据会溢出;DATETIME 的范围大得多,可以作为默认选择。
最后一件是别忘记定期做 MySQL 备份和慢查询日志分析。我用系统定时任务每天凌晨备份一次数据库,这个操作救过我一次,某个版本上线后有脏数据需要回滚,备份让我没有损失任何用户数据。慢查询日志则是我每周例行检查的对象,结合 EXPLAIN 分析执行计划,把新出现的慢查询逐个优化掉,这比等用户抱怨再排查要主动得多。
社区后端这套用户—文章—互动体系,本质上就是一个“人、内容、关系、动作”的四元模型。把四张核心表设计清楚,把索引和缓存的手段用到位,单库单表就能稳定扛起几十万用户的小型社区。我在实际开发中最深的体会是:表设计匆忙省下的思考时间,都会在后续的性能排查和数据修复里加倍还回来。每张表、每个索引、每个冗余字段的设计,都值得多问一句“这个字段未来会被怎样查询”。把这个习惯养成,比背多少条优化技巧都管用。