ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Web在线小说网站实战:表结构、阅读链路与优化

Java Web在线小说网站实战:表结构、阅读链路与优化 简介这份源码是一套基于Java Web开发的在线小说网站项目主要面向Java Web初学者和有课程设计、毕业设计需求的在校生。项目实现了小说搜索、分类浏览、章节阅读、文件下载等完整功能前端采用layui等组件后端由Servlet与JSP协同完成。压缩包共168个文件体积16.82MB核心内容包括46个Java源文件、28个JSP页面、20个HTML页面、12个XML配置、12个JavaScript脚本和8个CSS样式表另附字体、图片、Git忽略文件等辅助资源。源码采用分层结构包名与目录规划清晰Java文件按控制层、业务层与工具类分包JSP页面集中管理便于逐层理解请求处理、页面渲染和数据交互关键代码如登录校验、分页查询、文件流下载均有注释说明。目前已有414人学习适合作为项目实训、毕设参考或二次开发的基础。1. 为什么「基于 Java Web 的在线小说网站」是练手项目里最容易被低估的一个“基于Java Web的网络在线小说网站开发项目设计源码”——这类标题频繁出现在课设、毕设和外包需求单里也是新手把 Java Web 技术栈完整走一遍的经典载体。很多人第一反应是“这就是个 CRUD”用户、书籍、章节三张表拼几个页面就完了。但真正动起手来卡住你的根本不是表多而是阅读链路——章节目录、翻页、断点续读、后台审核状态机、批量导入章节。这些场景没有一个是“多写几个接口”能糊弄过去的全是设计取舍问题。这篇文章我会把这类项目拆成一套可以直接落地的方案表结构怎么建、前台阅读链路怎么做、后台发布状态怎么管、上线前要排查什么。适合正在选课设题目、接外包报价或者想拿一个完整的 Java Web 项目写进简历的开发者。2. 小说网站的表结构设计从 ER 关系到 DDL一次建对不返工2.1 五张核心表用户、小说、章节、书架、阅读记录小说网站和普通内容管理系统最大的区别在于用户行为路径非常固定——查书、看书、记录进度、回来看下一章。所以表结构不能按“功能模块”去堆而是按“一条完整的阅读链路”去设计。常见做法是先定五张核心表用户表、小说表、章节表、书架表、阅读记录表。书架和阅读记录都是典型的“用户与内容发生关系”的中间表但它们在写入频率和查询频率上完全不同必须拆开。我一般这样建表以 MySQL 8 为例CREATE TABLE user ( id BIGINT UNSIGNED AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password_hash VARCHAR(64) NOT NULL COMMENT BCrypt 哈希不存明文密码, avatar_url VARCHAR(255) NOT NULL DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE novel ( id BIGINT UNSIGNED AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 书名, author VARCHAR(64) NOT NULL DEFAULT COMMENT 作者笔名, category VARCHAR(32) NOT NULL COMMENT 分类玄幻/都市/历史…, audit_status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1待审 2上架 3下架, words_total INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 累计字数用于榜单排序, cover_url VARCHAR(255) NOT NULL DEFAULT COMMENT 封面存储路径, intro VARCHAR(1000) NOT NULL DEFAULT COMMENT 一句话简介, latest_chapter_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 最新章节ID冗余字段避免每次查max, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_status (category, audit_status), KEY idx_words (words_total) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小说主表;章节表的重点是 content 字段的类型选择CREATE TABLE chapter ( id BIGINT UNSIGNED AUTO_INCREMENT, novel_id BIGINT UNSIGNED NOT NULL, chapter_no INT NOT NULL COMMENT 章号从1开始保证连续, title VARCHAR(128) NOT NULL, content LONGTEXT NOT NULL COMMENT 正文使用LONGTEXT而不是TEXT, word_count INT UNSIGNED NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已发布, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_novel_no (novel_id, chapter_no), KEY idx_novel_status (novel_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT章节表; CREATE TABLE bookshelf ( id BIGINT UNSIGNED AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, novel_id BIGINT UNSIGNED NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_novel (user_id, novel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT书架表收藏一本小说; CREATE TABLE reading_record ( id BIGINT UNSIGNED AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, novel_id BIGINT UNSIGNED NOT NULL, chapter_id BIGINT UNSIGNED NOT NULL COMMENT 最后读到哪一章, scroll_offset INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 该章内滚动位置单位像素, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_novel (user_id, novel_id), KEY idx_user_updated (user_id, updated_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT阅读进度表;每个字段的取舍都对应一个实际场景。novel 表里的latest_chapter_id是故意冗余的否则首页每个小说都要SELECT MAX(chapter_no)一次几十本书就把数据库打满。bookshelf 用唯一键uk_user_novel防止同一本书收藏两次——这个唯一键会在并发点击收藏时帮你在数据库层面拦掉重复数据而不是靠业务代码先查后插。reading_record 也用了同样的唯一键保证每个用户每本书只有一条进度记录后续更新走ON DUPLICATE KEY UPDATE。2.2 MyBatis 联表查询的取舍什么时候 join什么时候拆开查很多同学一上来就把小说表和章节表 join 起来查目录理由是“目录页要显示书名和章节标题”。但章节目录这一页会返回少则几十、多则上千行这时候 join 的代价会被放大只要 novel 表这一行有任何字段参与 resultMap 的映射MyBatis 都会为每一行重复执行嵌套查询或重复赋值。常见做法是目录查询只查 chapter 表自身书名通过一次单独的SELECT title FROM novel WHERE id ?拿甚至可以在一开始就把它塞进 DTO。我在项目里通常这样处理 DTO 的组装public ChapterListVO listChapters(Long novelId, Integer page, Integer size) { // 先查章节分页只取chapter表自身字段避免join放大 PageChapter chapterPage chapterMapper.selectPage( new Page(page, size), new LambdaQueryWrapperChapter() .eq(Chapter::getNovelId, novelId) .eq(Chapter::getStatus, 1) .orderByAsc(Chapter::getChapterNo) ); // 书名、作者等信息单独查一次novel缓存到本地变量 Novel novel novelMapper.selectById(novelId); ChapterListVO vo new ChapterListVO(); vo.setNovelTitle(novel.getTitle()); vo.setAuthor(novel.getAuthor()); vo.setTotal(chapterPage.getTotal()); vo.setChapters(chapterPage.getRecords()); return vo; }这段代码的逻辑很简单但避免了两个典型问题。第一chapterNovel结对查询的 N1 问题目录页不会因为查了 200 章就执行 200 次SELECT * FROM novel WHERE id ?而是一次查完。第二分页和 join 的兼容问题MyBatis-Plus 的分页插件在碰到自定义 join 时count 查询经常会被解析错尤其当 SQL 里出现DISTINCT或子查询时。拆开来写之后分页查询的 SQL 变成了单表WHERE novel_id ? AND status 1 ORDER BY chapter_nocount 和 limit 都干净利落。这条经验在数据量超过几千章之后尤其明显单表分页 30 毫秒join 带分页能跑到 300 毫秒以上。2.3 章节内容存储单表 LONGTEXT 还是冷热分表章节的正文字段是整个项目里最占空间的。常见做法是全文只存一份用LONGTEXT而不是TEXT。原因很简单TEXT类型在 MySQL 里最多存 65,535 字节按 utf8mb4 编码换算下来两万字左右就满了而网文单章超过两万字的并不少见。一旦超过MySQL 会根据sql_mode选择截断或报错很多“某章后半段突然没了”的 bug 就是这么来的。LONGTEXT能存 4GB对小说正文来说完全够用。分不分表取决于预期的数据量。如果你做的是个人课设或小站点单表加LONGTEXT就够了千万级章节是运营两三年之后的事。如果明确要按生产标准来做常见方案是按novel_id做哈希分表比如准备chapter_0到chapter_15共 16 张表路由规则是novel_id % 16。分表之后跨表查询目录会变得很麻烦所以一般还会保留一个总目录索引表。对于这个项目标题的定位我不建议一上来就分表——先跑通单表把分表做成一个可替换的存储策略接口比急着把代码写死成 16 张表更划算。这个决定直接关系到后面的开发节奏。3. 前台阅读链路列表、章节正文、断点续读的一次性打通3.1 目录与详情接口的最小 Java 实现前台阅读链路是小说网站和后台管理系统的分水岭。后台怎么糙都行前台阅读只要慢 200 毫秒读者就会流失。最小可用的前台接口就三个小说详情、章节目录、章节正文。这三个接口里章节正文是重中之重因为它返回的数据最大而且会被连续请求——读者看完一章马上点下一章。我一般这样设计章节正文接口RestController RequestMapping(/api/novel) public class ChapterController { GetMapping(/{novelId}/chapters/{chapterNo}) public ResultChapterContentVO getChapter( PathVariable Long novelId, PathVariable Integer chapterNo, RequestParam(required false) Long userId) { Chapter chapter chapterService.getByNovelAndNo(novelId, chapterNo); if (chapter null || chapter.getStatus() ! 1) { return Result.error(章节不存在或未发布); } // 组装上一章、下一章的章号便于页面渲染“下一章”按钮 ChapterContentVO vo new ChapterContentVO(); vo.setTitle(chapter.getTitle()); vo.setContent(chapter.getContent()); vo.setPrevNo(chapterNo 1 ? chapterNo - 1 : null); vo.setNextNo(chapterService.getNextPublishedNo(novelId, chapterNo)); // 顺手记录阅读进度失败不影响正文返回 if (userId ! null) { readingRecordService.saveOrUpdate(userId, novelId, chapter.getId(), 0, 0); } return Result.success(vo); } }这个接口有一个容易被忽视的细节readingRecordService.saveOrUpdate被放在了正文返回之前但它的失败不能影响阅读。因为进度记录是弱一致场景哪怕写库失败了读者这一章照样能看完。如果直接把进度更新包在事务里一旦进度表锁冲突整个正文接口会被拖垮。这里用try-catch包住进度更新是个很实用的做法属于典型的“主链路和辅助链路分离”思路。getNextPublishedNo的实现要特别注意边界中间如果有章节被下架下一章的章号不是简单的chapterNo 1而是要查chapter WHERE novel_id ? AND status 1 AND chapter_no ? ORDER BY chapter_no LIMIT 1。否则就会出现“点下一章”跳到一屏幕空白的局面。3.2 阅读进度与“上一章 / 下一章”的边界处理进度记录表我设置了唯一键uk_user_novel更新时用ON DUPLICATE KEY UPDATE而不是先查后更。为什么不用先查后更因为在高并发下两次请求同时读到“没有记录”然后同时 insert必然有一个撞唯一键报错。用 upsert 语句把“查、插、更”合成一个原子操作是最省心的方案。public void saveOrUpdate(Long userId, Long novelId, Long chapterId) { String sql INSERT INTO reading_record (user_id, novel_id, chapter_id, updated_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE chapter_id VALUES(chapter_id), updated_at NOW(); jdbcTemplate.update(sql, userId, novelId, chapterId); }注意这里的细节唯一键是(user_id, novel_id)所以“同一本书永远只有一行进度”。我在实际项目中踩过这样的坑如果进度表没有唯一键每读一章插入一行一个月后这张表会膨胀到几百万行查询用户书架时慢得没法看。有了唯一键后这个 upsert 天然保证了“一人一书一行”进度表的大小严格等于活跃用户数乘以阅读书本数。返回书架列表时只需要 join 一次 reading_record 和 novel 表就能把“最近在读”和“上次读到第几章”一次性拉出来。另外scroll_offset字段是按像素记录读者在这一章里滚到了哪里。绝大多数移动端页面不需要精确到像素读者翻页之后回到书架重新进入能定位到章节就够了。所以进度更新的调用时机建议放在“章节正文加载完成”和“点击下一章”这两个时间点而不是监听滚动事件每秒上报一次——否则进度表会成为整个系统写入压力最大的表。3.3 热点书的缓存窗口怎么设前台阅读链路最怕的不是数据库查不出来而是一本热门书被同时几千人阅读时同一份章节正文被反复查库。章节正文是典型的“读多写少”且“内容不变”的数据发布之后几乎不会再修改非常适合缓存。常见做法是做一个两级缓存书籍信息和章节目录用本地缓存章节正文用 Redis。章节正文的 key 设计一般是novel:chapter:{novelId}:{chapterNo}过期时间我一般设 30 到 60 分钟。这里有一个需要想明白的边界章节发布后读者最长可能等多久才能看到新章节如果缓存设了 1 小时那么新章节发布后读者刷新目录页时可能会看到旧章节目录最长延迟 1 小时。网文读者的耐心很短常见做法是发布章节或者章节变更时主动删除对应的缓存 key而不是等着它自然过期。// 章节正文读取先查缓存命中直接返回 String key novel:chapter: novelId : chapterNo; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, ChapterContentVO.class); } Chapter chapter chapterMapper.selectByNovelAndNo(novelId, chapterNo); if (chapter null) { return null; } // 回填缓存设置随机过期时间防止缓存雪崩 int ttl 1800 new Random().nextInt(300); redisTemplate.opsForValue().set(key, JSON.toJSONString(chapter), ttl, TimeUnit.SECONDS);缓存回填阶段加随机过期时间是一个小技巧但非常关键。如果所有章节的过期时间都精确一致整点过期时 Redis 里的热门章节会同时失效数据库瞬间被打满这就是缓存雪崩。把 TTL 设成 1800 到 2100 之间的随机数能有效错开失效时间点。另外章节内容和章节目录的缓存必须分开存目录的粒度是“整本书的目录”正文的粒度是“单独一章”如果混在一个 key 里目录更新会导致所有章节缓存全部失效。4. 后台管理审核状态机与批量导入章节的事务边界4.1 审核状态机草稿、待审、上架、下架的流转设计小说网站的后台管理和一般内容系统最大的不同在于状态不是只有“上架”和“下架”两态而是有一套完整的审核流。我一般把novel.audit_status设计成 0 草稿、1 待审、2 上架、3 下架四个状态。章节的status字段则只保留 0 草稿和 1 已发布两个状态因为章节不需要独立下架——小说下架时它底下的章节全部不可见。这个设计可以避免出现“书在上架状态但某章被误删后变成空档”的尴尬。状态流转的核心规则是小说必须至少有一章已发布才能提交上架。这个约束如果放在前端校验后台直接调接口绕过去就全废了。我习惯把状态校验放在 Service 层接口只做参数透传下面这段代码是审核接口的核心逻辑Transactional(rollbackFor Exception.class) public void publishNovel(Long novelId, Long operatorId) { Novel novel novelMapper.selectById(novelId); if (novel null) { throw new BizException(小说不存在); } // 只有待审状态可以上架防止重复审核 if (novel.getAuditStatus() ! 1) { throw new BizException(当前状态不允许上架status novel.getAuditStatus()); } // 必须至少存在一章已发布内容 Long publishedCount chapterMapper.selectCount( new LambdaQueryWrapperChapter() .eq(Chapter::getNovelId, novelId) .eq(Chapter::getStatus, 1)); if (publishedCount null || publishedCount 1) { throw new BizException(至少有一章已发布才能上架); } // 审核通过更新状态并回填最新章节信息 novel.setAuditStatus(2); Chapter latest chapterMapper.selectLatestPublished(novelId); novel.setLatestChapterId(latest.getId()); novelMapper.updateById(novel); // 清理目录缓存让前台立即看到新状态 cacheService.deleteChapterList(novelId); }这里重点关注事务和缓存的顺序。事务提交之后才能清理缓存否则会有这么一种情况事务还没提交缓存先删了然后有请求查目录发现缓存为空回源数据库读到的是旧数据旧数据此时还在事务快照里重新写回缓存新章节就被缓存盖住了。我在项目里踩过一次这个坑现象就是“后台提示发布成功前台两天后才看到新章节”。后来改成事务提交后再删缓存或者干脆在事务内部删除并TransactionSynchronizationManager注册提交后的回调问题就消失了。4.2 批量导入章节的事务边界大事务与断点续传之间的平衡批量导入章节是小说站后台最常用的功能之一。管理员拿到一个 TXT 文件里面是几十章甚至几百章内容一次性导入。最简单的实现是把整个导入过程放在一个Transactional里任何一个章节解析失败全部回滚。听起来安全但实际使用起来会很难受——几百章的 TXT 导入可能要跑几十秒期间数据库连接被占用其他用户读正文也会受影响而且如果有中间一章因为格式问题失败前面 200 章全部白写。这是典型的大事务问题。我实际操作时采用的方案是“分批提交加断点续传”每 50 章一个批次批次内用事务批次之间独立提交。哪一批失败就直接停止导入并记录失败的起始章号管理员修正 TXT 后可以从断点继续导入。核心代码如下public BatchImportResult importChapters(Long novelId, ListChapter chapters) { BatchImportResult result new BatchImportResult(); int batchSize 50; for (int start 0; start chapters.size(); start batchSize) { int end Math.min(start batchSize, chapters.size()); ListChapter batch chapters.subList(start, end); try { importBatch(novelId, batch); result.setSuccessCount(result.getSuccessCount() batch.size()); } catch (Exception e) { result.setFailStartChapterNo(batch.get(0).getChapterNo()); result.setErrorMsg(第 batch.get(0).getChapterNo() 章开始导入失败 e.getMessage()); break; } } return result; } Transactional(rollbackFor Exception.class) public void importBatch(Long novelId, ListChapter batch) { for (Chapter chapter : batch) { chapter.setNovelId(novelId); chapter.setStatus(1); chapterMapper.insert(chapter); } Novel novel novelMapper.selectById(novelId); novel.setWordsTotal(novel.getWordsTotal() batch.stream().mapToInt(Chapter::getWordCount).sum()); novelMapper.updateById(novel); }注意importBatch是 public 且通过 this 调用时事务才生效。Sping 的Transactional是基于 AOP 代理实现的如果我在同一个类里直接调用this.importBatch(...)事务注解不会生效。所以我把importBatch放到了一个独立的 Service 类中或者使用依赖注入调用自身代理。很多人在这个细节上栽跟头导出的章节数据出现“一半成功一半没有”时第一反应是数据库问题其实是事务根本回滚不了。4.3 文件上传与内容安全编码探测和统一转码后台导入的 TXT 文件编码五花八门。直接用FileReader读默认按平台编码用InputStreamReader不指定字符集UTF-8 的机器读 GBK 文件读出来全是“锟斤拷”。常见做法是先探测编码再转码。public String detectAndRead(InputStream in, String defaultCharset) throws IOException { // 读取前3个字节判断BOM头 PushbackInputStream pb new PushbackInputStream(in, 3); byte[] head pb.readNBytes(3); String charset; if (head.length 3 (head[0] 0xFF) 0xEF head[1] 0xBB head[2] 0xBF) { charset UTF-8; // 跳过BOM不推回 } else { // 把读过的字节推回流中按默认编码继续读 pb.unread(head); charset defaultCharset; // 一般传 GBK } // 用探测出的编码读取全文 String content new String(pb.readAllBytes(), Charset.forName(charset)); // 统一转成UTF-8入库 return new String(content.getBytes(charset), StandardCharsets.UTF_8); }这段代码的关键在于 BOM 头的处理。utf-8文件带 BOM 时前三个字节是EF BB BF如果不跳过第一个章节标题前会多出一个不可见字符读者在阅读页会看到标题前面多一个空行。而 GBK 文件没有 BOM 头读前三个字节后必须unread回去否则会丢内容。这个场景属于典型的“看起来是小功能实际上全是边界”的类型——处理不当导入 300 章后才发现每章都多了一个字符返工成本极高。5. 排查篇小说站从开发到部署最容易翻车的五个现场5.1 章节正文被 MySQL 静默截断后半章凭空消失现象后台导入章节时一切正常前台阅读到某一章中途内容突然断掉页面上有的章节结尾只有半句话。原因MySQL 的TEXT类型最大容量只有 65,535 字节。网文单章经常超过 2 万汉字按 utf8mb4 编码就是 8 万字节左右直接超出容量。建表时如果用的是TEXT写入时会触发严格模式报错但有些导入代码里 catch 了异常继续执行导致这一章“看起来导入成功”实际只写进去了一段。解决把chapter.content的字段类型改成LONGTEXT。检查所有历史表执行ALTER TABLE chapter MODIFY COLUMN content LONGTEXT NOT NULL;。同时检查 MySQL 的max_allowed_packet参数如果章节内容较大且参数太小链接层就会直接断掉报错信息是 “Packet for query is too large”。课后设和中小项目建议一开始就把content定为LONGTEXT不要省。5.2 章节目录出现重复行总页数对不上现象前台章节目录第一次打开正常翻到后面页数越来越多或者同一章出现两次后台统计章节数和数据库实际对不上。原因查询目录时用chapter LEFT JOIN novel拿书名但novel_id如果没建索引MySQL 优化器会选错执行计划或者 MyBatis 的 resultMap 配了一对多映射但集合的 primary key 没有显式声明导致每一行 chapter 都生成了一个新对象而不是合并到已有对象上。这个现象在 500 章以上的书里几乎必现。解决目录查询只查 chapter 单表书名通过一次额外的查询塞进 DTO如果必须用 resultMap给collection标签显式声明column对应到主键id。最稳妥的还是第一条——不要 join就没有重复的可能。分页总数也只对 chapter 表做 count。5.3 事务方法自调用导致审核发布“半成功”现象后台审核上架一本小说提示成功但前台看不到这本书或者章节只有前 50 章、后 100 章没进去查看数据库发现novel.audit_status是 2但章节确实只导入了一半。原因Transactional注解失效。最常见的原因是在同一个类里用this.importBatch(batch)调用事务方法AOP 代理没有经过这个调用事务根本没有开启。另一个原因是异常被业务代码catch后吞掉事务感知不到异常自然不回滚。解决事务方法写到独立的 Service 类中通过注入的 Bean 调用Transactional一定标志rollbackFor Exception.class不要只写Transactional。事务方法内部如果必须 catch 异常应当在 catch 块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()或者干脆把异常重新抛出。5.4 深分页翻目录越来越慢从 30ms 退化到 2s现象小说章节多的书目录前几页很快翻到后面时接口耗时迅速上升查看慢查询日志发现CHAPTER表ORDER BY chapter_no LIMIT 2000,20这类语句。原因MySQL 的LIMIT offset, size在 offset 很大时会把前 offset 行全部扫描一遍再丢弃。章节表几千行时感觉不到一旦超过十万行深分页就是灾难。解决章节目录天然有chapter_no这个连续递增的业务序号应该用基于游标的分页代替 offset 分页。接口改为WHERE novel_id ? AND status 1 AND chapter_no ? ORDER BY chapter_no LIMIT 20前端传上一页最后一个chapter_no而不是页码。这样无论翻到多少页查询都走uk_novel_no唯一索引耗时恒定在毫秒级。5.5 上传 TXT 导入后全篇乱码现象用浏览器上传 TXT 文件后后台预览第一章一切正常但从第 5 章开始全是“锟斤拷”和“”有的章节标题混入奇怪字符。原因上传的文件实际上不是 UTF-8 编码而是 GBK 或 GB18030。浏览器端预览用的是 FileReader 默认按 UTF-8 解码遇到 GBK 编码的中文就会显示乱码这给了你“文件没问题”的错觉。后台处理时又用InputStreamReader默认字符集读了一次双重错误叠加最终入库的内容彻底损坏。解决上传接口统一走编码探测流程先检测 BOM 头再用探测到的编码读取最后统一转成 UTF-8 存储。转码后不要直接入库最好把前 10 行文本返回前端预览让管理员确认无乱码后再提交导入。编码探测看起来是个小功能但它是批量导入模块里返工率最高的一个环节值得单独写一个工具类来管理。6. 上架前的验证清单索引、慢查询和阅读体验的三个硬指标6.1 用 EXPLAIN 验证核心查询我每次上线前会固定跑六个核心查询的EXPLAIN小说分页列表、章节目录、章节正文、阅读记录 upsert、书架列表、最新章节回填。这六个查询覆盖了用户从搜索到阅读的全部路径。检查标准很简单type至少是ref不能是ALLrows不应该超过一万。如果EXPLAIN显示章节表扫描 10 万行说明idx_novel_status索引没有建对或者查询里条件顺序不对。例如这个最容易犯罪的查询最容易忽略索引顺序EXPLAIN SELECT * FROM chapter WHERE novel_id 100 AND status 1 ORDER BY chapter_no LIMIT 20;如果只建了idx_novel(novel_id)MySQL 需要先过滤 5000 行 status 再做排序性能会很差。正确索引是idx_novel_status(novel_id,status,chapter_no)这个联合索引可以直接覆盖“过滤排序”两个阶段。通过EXPLAIN的Extra字段如果看到Using filesort索引就得调整。6.2 慢查询日志和压测我习惯在上线前用ab或curl模拟读者点击路径连续打 5 分钟的接口然后立刻看 MySQL 慢查询日志。long_query_time设置成 0.5 秒凡是被打出来的 SQL 都要逐个过一遍。# 模拟100个并发用户连续请求章节正文接口5分钟 ab -n 20000 -c 100 http://localhost:8080/api/novel/100/chapters/1 # 查看慢查询日志 tail -n 50 /var/log/mysql/mysql-slow.log压测时重点关注两个指标Failed requests是否为 0以及请求的Time per request的 95 分位值。如果 95 分位超过 500ms我一般直接检查是不是 Redis 没命中——比如注入的 RedisTemplate 序列化方式配错导致 key 和实际存储不一致热点书的缓存命中率会变成 0整条链路退化成直接打数据库。6.3 断点续读的端到端验证断点续读是最容易在回归测试时漏掉的功能因为它只在“第二次进入同一本书”的时候触发。我的验证方法是行为模拟用户读第 10 章滚动到页面中段退出阅读页再次进入这本书断言跳转的目标章节是第 10 章而不是第 1 章接着点击下一章断言第 11 章正文内容加载成功且上一章按钮指向第 10 章。这里有一个常被忽略的坑如果把“上一章 / 下一章”的算法写成“当前 chapter_no 加 1”一旦中间有章节被后台下架读者点下一章就会跳到“内容不存在”页面。所以上一章和下一章的查询都必须带status 1的条件。这个细节我在上线后才遇到运营下架了一章涉敏内容结果所有读完前一章的读者下一章全部报错花了一个晚上才定位到问题。验证完这三项后我建议再加一个数据巡检随机抽 10 本书每本抽查首章、中章、末章各一章确认正文长度非空、字数统计一致、阅读进度能正确回跳。数据没问题再把服务挂到生产环境。写到这里顺便说一句我的习惯每次上架新功能我都会在本地先跑一遍这六个 EXPLAIN 加一条端到端阅读链路跑通再合代码。这套流程保证我被运营和读者夹在中间的时候至少代码不会成为那个半夜响起来的报警电话。希望帮到你。本文还有配套的精品资源点击获取
返回列表