数码论坛系统从零到上线:表结构、缓存、并发与内容治理 做数码类社区和做通用论坛表面看只是换个主题皮肤真上手写代码才知道差得远。数码论坛的用户群体有几个很鲜明的特征发帖必带图参数党喜欢长文加表格回复里动不动就是几十层的追问和对比还有一批人专门蹲二手交易区。这三件事叠加起来对系统的图片存储、长文本渲染、楼层结构、并发写入都提出了完全不同的要求。我这次要聊的就是一套完整的数码论坛系统从零到上线是怎么设计出来的——用什么技术栈、表怎么建、发帖和列表这两条最热的链路怎么扛、缓存怎么切、版务治理怎么落到代码里以及我自己在压测和真实流量里踩到的坑。适合正在做社区类项目、或者准备把课程设计做成能真跑起来的同学看也适合已经写过论坛但被性能和内容治理卡住的人对照着排查。1. 数码论坛的产品形态决定了技术走向1.1 数码论坛和普通社区的三处硬差异第一处差异是内容重量级。一个普通灌水帖可能就几十个字但数码区一篇某款笔记本深度拆机的帖子正文五千字、配图三十张、还夹着参数表格单条记录的正文体积能到 200KB 以上。这意味着 posts 表的长文本字段不能和列表查询用的字段混在一张宽表里反复扫否则列表页的 IO 会被彻底拖垮。第二处差异是板块的层级深度。数码论坛的板块天然是树状的手机数码 → 安卓阵营 / 苹果生态 → 各家品牌子版影像器材 → 相机 / 镜头 / 无人机。层级一深导航面包屑、权限继承、帖子归属校验就全都要围绕树来做用一张扁平的 category 表加 parent_id 递归查询在板块数量上百之后会明显变慢。第三处差异是回复的引用链文化。数码区讨论经常出现 A 楼说某个参数B 楼反驳C 楼再引用 B 楼的引用一条讨论串能有七八层嵌套。如果只是把回复做成一条扁平列表用户体验会很差但做成真正的无限嵌套树渲染和分页又会变得极其麻烦。我最后采用的是两层半方案后面第 3 节会详细讲。1.2 从信息架构反推数据模型动手写表之前我习惯先把信息架构用最简单的文字列出来这一步花半小时能省后面两周的返工。数码论坛的核心实体其实就六个用户、板块、帖子、回复、附件、互动记录点赞收藏举报。关系上要提前想清楚三件事帖子和板块是多对一还是多对多论坛的常规做法是一对多一个帖子只归属一个板块但支持移动板块操作这就需要记录操作日志而不是直接改外键了事。附件和帖子是强绑定还是弱绑定我选弱绑定——附件先上传拿到 URL发帖时再把 URL 写进正文附件表单独记录归属。这样做的好处是支持草稿箱和编辑器里的上传了但没发出去的图。互动记录要不要独立成表点赞、收藏这类高频写操作必须独立成表并加唯一索引绝不能塞进帖子表做 count 字段的实时更新。1.3 用户角色与权限边界数码论坛的角色比想象中多游客、注册用户、认证用户比如发过优质内容被打标、版主、超级管理员再加上二手交易区需要的实名/信用标记。如果用传统的 RBAC 五张表全量展开对这个体量的项目是过度设计。我的做法是角色用枚举 权限用位运算。用户表存一个 role 字段tinyint和一个 permission_maskbigint每个权限位代表一种能力比如发帖、置顶、删帖、封禁、看隐藏版块。判断权限时直接做位与运算一次 CPU 操作搞定不用查关联表。角色变更时重新计算 mask 即可代码里维护一张角色 → mask的映射表。这套方案在用户量十万级以内完全够用需要精细化时再平滑迁移到标准 RBAC。2. 技术选型为什么最后落在这一套组合上2.1 后端框架的选择过程候选方案我实际都搭过 demoSpring Boot 3 MyBatis-Plus、Spring Boot JPA、以及 Node 侧的 NestJS。最后定 Spring Boot 3 MyBatis-Plus理由很具体。论坛系统里列表查询占 SQL 的七成以上而且条件组合极其灵活——按板块、按标签、按时间、按热度、按作者、只看精华、排除已删。JPA 的 Criteria API 写这种动态组合查询代码量会膨胀到难以维护MyBatis-Plus 的 QueryWrapper 加自定义 XML复杂查询和简单 CRUD 各走各的路可读性好得多。另外这个项目涉及大量批量更新和原地自增浏览量、回复数、点赞数MyBatis 对原生 SQL 的掌控力在这种场景下明显更舒服。Spring Boot 3 带来的虚拟线程支持也值得提一句。论坛的 IO 密集型接口发帖时写库 存附件 发通知用虚拟线程后我不用再把同步代码硬拆成响应式吞吐提升明显代码却更好读。2.2 存储层的三层结构存储我分了三层各司其职层组件承担职责关键考虑关系型MySQL 8.0用户、板块、帖子元数据、回复、互动记录事务保证主从分离读缓存Redis 7热点帖子、板块树、会话、计数器、分布式锁单实例内存 8G 起步对象存储兼容 S3 协议的服务帖子图片、用户头像、附件走 CDN 回源绝不落本地磁盘这里有个新手最容易犯的错把图片存服务器本地磁盘然后直接暴露路径。上线第三天磁盘就满了扩容还得迁移数据。对象存储 CDN 是社区类项目的标配前期多花半天接入后面省无数事。2.3 搜索为什么一开始没用 Elasticsearch全文检索这块我犹豫了很久。Elasticsearch 功能强但对一个刚起步的论坛来说它带来的运维成本集群、内存、索引同步远大于收益。我的策略是分两步走第一阶段用 MySQL 全文索引配合 ngram 分词器做站内搜索只搜标题和正文前 500 字同时用 Redis 缓存热门搜索词的结果。这个方案在数据量百万级以内、日均搜索几千次的情况下完全撑得住。第二阶段等帖子量过百万、搜索响应超过 500ms再把 ES 接进来用 Canal 或者应用双写做索引同步。提示搜索功能不要一开始就追求精准社区搜索的第一诉求是快。用户搜一个型号名能秒出结果比排序多精细重要得多。3. 表结构设计字段和索引里藏着性能3.1 帖子表把冷热字段分开这是整个项目最关键的一张表。我先给出简化版结构CREATE TABLE post ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, category_id INT UNSIGNED NOT NULL COMMENT 所属板块, user_id BIGINT UNSIGNED NOT NULL COMMENT 作者, title VARCHAR(120) NOT NULL, summary VARCHAR(255) NOT NULL DEFAULT COMMENT 纯文本摘要, content MEDIUMTEXT NULL COMMENT 正文HTML, cover_url VARCHAR(512) NOT NULL DEFAULT , tags VARCHAR(255) NOT NULL DEFAULT , status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1待审 2已发布 3已删 4屏蔽, is_top TINYINT NOT NULL DEFAULT 0, is_essence TINYINT NOT NULL DEFAULT 0, view_count INT UNSIGNED NOT NULL DEFAULT 0, reply_count INT UNSIGNED NOT NULL DEFAULT 0, like_count INT UNSIGNED NOT NULL DEFAULT 0, last_reply_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_cat_status_time (category_id, status, is_top DESC, last_reply_at DESC), KEY idx_user_status (user_id, status, created_at DESC), KEY idx_last_reply (last_reply_at DESC) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;几个设计点值得展开说。正文为什么单独存一列而列表不查它MySQL 的行存储里MEDIUMTEXT 这类大字段会走溢出页一旦 SELECT * 就会产生额外的随机 IO。所以列表查询的 SQL 我全部显式指定字段绝不写 SELECT *。更进一步的做法是把 content 拆到独立的 post_content 表但那样每次详情页要多一次 join。我实测下来只要列表不查 content单表方案完全够还省了一次 join就没拆。数据量再大可以考虑拆。summary 字段是冗余但值的。列表页要显示正文前两行如果每次都从 content 里截取等于把大字段捞出来了。发帖时生成一次纯文本摘要存下来列表页直接读这个冗余非常划算。排序索引里的 is_top DESC。置顶帖要排在最前面非置顶按最后回复时间倒序。把 is_top 放进联合索引的第二位、让排序字段跟在后面MySQL 8.0 支持降序索引这样 ORDER BY is_top DESC, last_reply_at DESC 可以直接走索引不用 filesort。这个细节对首屏加载速度的影响相当大我在压测里对比过同样一万条数据走索引和 filesort 差了将近 8 倍。3.2 回复表的两层半结构先说结论不用无限嵌套用两层半。CREATE TABLE reply ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL, parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 直接父回复, root_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 根楼层用于聚合, floor_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 楼层号, reply_to_uid BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 被回复人, content TEXT NOT NULL, like_count INT UNSIGNED NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_post_floor (post_id, status, floor_no), KEY idx_root (root_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;所谓两层半指的是一级回复直接回复主帖是第一层占用楼号按时间正序排列对一级回复的回复全部归到同一个 root_id 下面是第二层第二层再往下的回复不再单独缩进而是显示某某 回复 某某折叠展示前三条点开看全部。这个方案解决的核心痛点是分页。真正的无限嵌套树没法分页一个热帖几千条回复前端渲染会直接卡死。两层半结构下第一层可以正常分页每页 20 楼第二层按 root_id 一次查出来通常不会超过几十条用 IN 查询就能拿全。floor_no 的生成必须小心第 8 节会专门讲这里的并发坑。3.3 互动表的唯一索引防重点赞、收藏这类表核心设计就一条唯一索引。CREATE TABLE post_like ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_post_user (post_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用 INSERT IGNORE 或者 ON DUPLICATE KEY UPDATE 来写入重复点赞自然被拦掉不需要先查后插那会有并发竞态。取消点赞就 DELETE。听起来简单但很多项目的点赞数对不上根源就是没有唯一索引用户快速双击写了两次。计数器我用 Redis 做缓冲比如点赞后先 INCR Redis 里的计数再用定时任务或者消息队列异步批量刷回 MySQL。这样高并发点赞不会把 MySQL 打爆代价是短时间内的数值是最终一致的对社区场景完全可接受。4. 核心链路发帖、列表、图片、通知4.1 发帖链路的分步拆解发帖看起来是个简单接口但它是整个系统里逻辑最重的一条链路。我的实现分成七步参数校验标题长度、板块存在性、用户是否有该板块发帖权限、是否被禁言。内容清洗用白名单式的 HTML 过滤只放行 p、br、strong、em、img、a、table 等标签去掉 script、on* 事件、iframe。这一步必须做用户从别处复制的富文本里带的脏东西比想象中多。敏感词检测走 DFA 自动机命中后根据词级别决定是拒绝、转待审还是替换星号第 6 节详述。生成摘要剥离所有 HTML 标签取纯文本前 120 字。落库写入 post 表同时把正文里引用的附件从临时态标记为已绑定。异步副作用更新板块发帖数、给关注该板块的用户推通知、写操作日志、投喂搜索索引。返回返回帖子 ID 和跳转 URL。第 6 步一定要异步。我一开始图省事全写同步结果发帖接口 P99 到了 800ms因为要串行做四五件事。拆成异步之后降到 120ms 左右。异步我用的是本地消息表 定时轮询没用 MQ——这个体量下引入 MQ 只会增加运维负担本地消息表已经足够可靠。4.2 列表分页OFFSET 的陷阱帖子列表用 LIMIT offset, size 是最直觉的写法但它是性能杀手。当 offset 到 5 万时MySQL 要扫描并丢弃前 5 万行耗时随页码线性增长。压测数据很直观分页方式第 10 页第 100 页第 1000 页LIMIT offset18ms210ms2100ms游标分页last_reply_at ?15ms16ms17ms延迟关联先查 ID 再回表16ms45ms320ms我最终用了游标分页为主、延迟关联为辅的组合。信息流式的列表首页、板块列表用游标分页前端传上一页最后一条的 last_reply_at 和 id 作为游标SQL 变成SELECT id, title, summary, cover_url, user_id, reply_count, last_reply_at FROM post WHERE category_id ? AND status 2 AND (last_reply_at, id) (?, ?) ORDER BY last_reply_at DESC, id DESC LIMIT 20;需要显示第几页的传统翻页场景比如后台管理、搜索结果用延迟关联SELECT p.* FROM post p INNER JOIN ( SELECT id FROM post WHERE category_id ? AND status 2 ORDER BY is_top DESC, last_reply_at DESC LIMIT 100000, 20 ) t ON p.id t.id;子查询只走覆盖索引拿 ID不回表最后再按 20 个 ID 精确回表代价小很多。注意游标分页有个体验上的副作用——用户看不到总页数。我的处理方式是在列表顶部返回一个约 X 条讨论的估算值用 Redis 里的板块计数缓存不追求精确。4.3 图片上传的三个必做动作数码论坛图片量巨大上传这块我定了三条硬规则。客户端先压缩再上传。用 canvas 在前端把超过 2000px 宽的图等比缩到 1600px质量压到 0.85一张手机拍的 5MB 照片能压到 600KB 左右。这一步在移动端能省掉 80% 的上传流量。服务端二次校验。前端压缩不可信服务端必须重新检查 MIME 类型不能只看扩展名、文件头魔数、单文件大小上限我设的 10MB、以及用图片处理库重新编码一遍。重新编码这一步是关键——它能干掉藏在图片里的可执行代码是防图片木马最有效的手段。上传走直传。文件不经应用服务器前端向服务端要一个带签名的临时凭证直接传到对象存储传完回调服务端记一条附件记录。应用服务器只处理几百字节的签名请求完全不会被大文件拖累。这套方案上线后应用服务器的带宽占用降到了原来的十分之一。4.4 未读提醒别用轮询新回复、被 、被点赞这几类通知如果让前端每 5 秒轮询一次接口一万在线用户就是每秒 2000 次请求纯属浪费。我的方案是Redis 计数 长轮询降级。用户未读总数存在 Redis 的一个 hash 里前端每 30 秒拉一次总数很轻有变化才去拉详情。同时保留一个长轮询接口hold 住 25 秒在需要准实时的高优先级通知比如被 时使用。WebSocket 我也评估过但线程和连接维护成本对这个项目偏高长轮询的体验已经够用。5. 缓存切分与并发写入的处理5.1 缓存粒度的选择缓存不是加得越多越好粒度错了反而更慢。我按数据特征分了三类处理。板块树变化极少、读取极频繁直接整棵树序列化成 JSON 存 Rediskey 是forum:tree:v1不设过期时间板块有变更时主动删 key 重建。这棵树每次页面渲染都要用本地缓存Caffeine再加一层能挡掉 95% 的 Redis 请求。帖子详情只用 Redis 缓存热帖。判定标准是最近一小时内浏览量超过阈值。冷帖直接从 MySQL 读反正也慢不到哪去。缓存 key 用post:detail:{id}TTL 设 10 分钟加随机抖动防止同一时刻大批 key 同时失效。列表结果只缓存第一页。第一页的访问量占整个列表的 70% 以上缓存它性价比最高。TTL 设 30 秒用户几乎感知不到延迟但数据库压力立降。5.2 穿透、击穿、雪崩的针对性处理这三个问题是缓存必答题我的处理方式是分开对待不搞一刀切的方案。穿透——查一个不存在的 ID每次都打到数据库。处理方式是空值缓存加短 TTL60 秒同时用布隆过滤器在入口挡一层。布隆过滤器的容量按帖子总数预估误判率设 0.1%占用内存很小。击穿——某个热点帖子缓存过期瞬间大量请求同时回源。处理方式是用 Redis 分布式锁只放一个请求去查库重建缓存其他请求等锁释放后读缓存。锁的 key 是lock:post:{id}超时设 3 秒防止死锁。雪崩——大批 key 同时过期。处理方式是 TTL 加随机抖动、缓存永不过期配合主动更新、以及降级预案Redis 挂掉时直接放开流量到数据库同时开启限流保护。分布式锁这里有个坑必须提加锁和解锁必须带唯一值校验不能用简单的 SETNX 加 DEL。因为如果业务执行超过锁超时时间锁提前释放另一个线程拿到锁此时第一个线程执行完再 DEL就会误删别人的锁。正确做法是 value 存一个 UUID解锁时用 Lua 脚本比对后删除。5.3 计数器的原子性与最终一致浏览量、点赞数、回复数这三个计数器是并发问题的高发区。浏览量我用 Redis 的 Hash 按帖子聚合每个请求 HINCRBY同时用一个 Set 记录该帖子在本次刷写周期内被哪些用户看过防止同一用户刷新刷出几十次。定时任务每 30 秒把 Hash 里的增量批量 UPDATE 回 MySQL。点赞数前面说过写 Redis 计数异步落库。回复数用更直接的方式——发布回复成功后在同一个事务里UPDATE post SET reply_count reply_count 1 WHERE id ?用数据库行锁保证原子不走应用层先读再写。这一条 SQL 本身就是原子的不需要额外加锁。提示所有计数器的最终一致性延迟要写进产品说明里避免用户反馈我明明点了赞数字没变。我做的处理是前端乐观点赞——点击立刻把数字加一后台异步同步失败了静默回滚。6. 内容治理从敏感词到审核状态机6.1 敏感词过滤的 DFA 实现社区类项目绕不开内容过滤。我用的是 DFA确定有限自动机算法核心思路是把所有敏感词构建成一棵 Trie 树然后用三个指针当前节点、起始位置、当前位置在文本上滑动匹配。相比逐个词去 contains 判断复杂度从 O(词数 × 文本长) 降到 O(文本长)词库上万级别时差距非常明显。词库的存储方式我做了两层一层是代码里内置的基础词库启动时构建自动机一层是存在数据库里的可运营词库每隔 5 分钟热加载一次。词分级处理级别处理方式说明高危直接拒绝发布返回明确提示不给过中危转待审核帖子可见但只有作者自己能看到低危自动替换为星号用户无感知但内容被清洗自动机构建和热加载要注意线程安全我用的是写时复制——新词库构建完成后原子替换引用读线程永远读到一个完整可用的自动机实例不需要加锁。6.2 审核状态机的设计帖子从创建到最终状态流转路径必须明确否则运营后台会出现帖子状态混乱的经典问题。我定义了这几个状态0 草稿用户自己编辑中只有本人可见1 待审命中中危词或新人首帖进入人工队列2 已发布正常可见3 已删除软删除数据保留4 已屏蔽违规只有管理员可见附违规原因状态迁移我封装成一个状态机方法只允许特定的迁移路径比如 1 → 2、1 → 4、2 → 4、4 → 2其他迁移直接抛异常。这么做的好处是所有状态变更都收口在一处加日志、发通知、清缓存这些副作用统一挂在这里不会散落在业务代码里。6.3 举报与分级处罚举报功能的关键是去重和聚合。同一个人反复举报同一个帖子只算一次用唯一索引拦不同人举报同一帖子要聚合计数。当举报数达到阈值比如 5 人帖子自动转入待审状态。处罚也分级轻度警告站内信 记一次违规、中度禁言1~7 天、重度封号永久可申诉。每条处罚记录都写进独立的表包含操作人、原因、时长、生效时间这样用户申诉时有据可查。所有封禁操作都必须落到日志不能只改用户状态字段。7. 部署、压测与调优实录7.1 部署拓扑与关键配置线上配置是两台 4C8G 应用服务器 一主一从 MySQL8C16G 单实例 Redis8G 内存 对象存储。应用前面挂负载均衡会话用 Redis 共享不依赖粘性会话。这个配置能扛住日均 UV 两万左右、峰值在线三千的规模。启动参数上几处调整效果明显java -Xms4g -Xmx4g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump \ -jar forum.jarXms 和 Xmx 设成一样大避免堆频繁伸缩G1 的停顿目标设 200ms对交互式接口比较友好开启 OOM 时自动 dump出问题了好排查。HikariCP 连接池的参数我调整过好几轮最终值是最大连接 30、最小空闲 10、连接超时 3 秒、最大生命周期 20 分钟。连接数不是越大越好MySQL 侧的活跃连接超过一定数量上下文切换的代价会反噬吞吐。7.2 压测数据与瓶颈定位用 JMeter 做压测场景分三组只读列表、发帖、混合读写。一组关键数据场景并发QPSP95 延迟瓶颈列表首页走缓存200420046ms无列表翻页走库200780210ms联合索引未命中发帖100320180ms同步副作用混合3001500320ms数据库连接池第一轮压测最惨的是列表翻页P95 到了 210ms。用慢查询日志一看翻到深处时 idx_cat_status_time 没被用上走了 filesort。原因是 ORDER BY 里的 is_top DESC 和索引顺序不一致调整索引顺序后降到 60ms 以内。第二轮是发帖场景把同步副作用改成异步后P95 从 800ms 降到 120ms。第三轮混合压测时连接池被打满原因是几个统计接口没走索引做了全表扫描属于典型的慢接口拖垮连接池。这类问题的定位方法很简单看连接池的活跃连接数是否长期贴顶配合慢查询日志基本能一找一个准。7.3 从监控反推优化点上线后我盯了三周的监控主要看四个指标接口 P95、慢查询数量、Redis 命中率、GC 频率。Redis 命中率一开始只有 78%偏低。排查发现是缓存 key 设计有问题——我把用户维度的是否已点赞状态也塞进了帖子详情缓存导致缓存和用户强绑定命中率自然上不去。拆出来单独用like:{postId}:{userId}这种短 key 之后命中率升到 94%。GC 频率方面Young GC 每分钟 3~5 次属于正常但如果某天突然翻倍通常是某个接口开始产生大量临时对象。我遇到过一次是帖子详情的 JSON 序列化用了反射方式换成手动拼装 DTO 后降下来了。8. 踩过的坑和解法8.1 楼层号并发重复最离谱的一个 bug同一篇帖子出现了两个3 楼。原因是我的实现是先查当前最大楼层再插入楼层1两个用户同时发回复时都查到了 2都插入了 3。这是教科书级的竞态条件但我确实踩了。修法有三种我最终选了第三种给 post 表加 reply_seq 字段回复时UPDATE post SET reply_seq reply_seq 1再读回来用行锁保证唯一。缺点是每次回复都要锁主帖行。用数据库的自增 ID 直接当楼层号。缺点是删了回复会跳号用户看着别扭。用 Redis 的 INCR 分配楼层号同时在 reply 表上给 (post_id, floor_no) 加唯一索引兜底。我选第三种。Redis INCR 是原子的性能好唯一索引兜底能保证即使 Redis 出问题也不会写脏数据万一真冲突了捕获唯一键异常重试一次即可。这里唯一的坑是 Redis 和数据库要保证楼层号不脱节——帖子删除重建楼层时必须同时清理 Redis 里的 key。8.2 图片外链与防盗链数码论坛的图片被盗链是常态。我做了三层防护对象存储侧配置 Referer 白名单所有图片 URL 都带时效签名有效期 2 小时图片域名和主站域名分开避免 CDN 被刷时影响主站。签名 URL 有个副作用——浏览器缓存会失效因为每次 URL 都变。我的折中方案是列表页和详情页用长有效期签名24 小时编辑器预览用短签名。同时给图片响应头加上长缓存时间只要 URL 不变就能吃满缓存。8.3 时区与时间格式的坑这个坑很隐蔽。我的服务器是 UTC 时区数据库连接串没显式指定时区应用层用 LocalDateTime 处理。结果用户发帖时间和实际时间差了 8 小时。根源是 JDBC 连接的 serverTimezone 默认值在不同版本行为不一致加上 LocalDateTime 不带时区信息整条链路是看起来对实际错。修法是数据库统一存 UTC 时间用 DATETIME 存 UTC 值应用层统一用 Instant 或者带时区的 OffsetDateTime返回给前端时再转成用户所在时区。前端用 ISO 8601 格式接收。这套规则定死之后再没出过时间问题。提示不要用 TIMESTAMP 类型存业务时间它的自动转换行为在不同数据库配置下容易出幺蛾子DATETIME 更可控。8.4 冷数据的归档策略论坛跑一年后post 表到了八百万行列表查询开始变慢。我的处理不是分库分表——这个体量还远没到那一步——而是做冷热分离。判定标准超过 18 个月且最后回复时间在 12 个月前的帖子且浏览量低于 1000全部归档到 post_archive 表。归档后用触发器或者定时任务保证查询能同时命中两张表用 UNION ALL 应用层路由。归档了约 300 万行之后主表索引体积小了三分之一列表查询恢复到 40ms 以内。归档任务的实现有个细节不能一次性 DELETE 三百万行那会导致主从延迟飙升甚至锁表。我是分批做每批 5000 行批间隔 200ms凌晨低峰期执行整体跑了两小时。期间主从延迟稳定在 1 秒以内。最后分享一个我在这个项目里体会最深的小经验论坛这类系统的性能问题九成以上不是出在架构不够先进而是出在几条没走对索引的 SQL 和几个同步阻塞的副作用上。我上线前花了两天专门做慢查询治理和链路异步化效果比后来折腾的任何架构升级都明显。真正把每条 SQL 的执行计划看懂、把每个接口的耗时拆开量一遍比盲目引入一堆中间件有用得多。