☰
Java Web在线小说网站实战:表结构、阅读链路与优化
2026/10/7 5:50:43 网站建设 项目流程

简介:这份源码是一套基于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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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放大 Page<Chapter> chapterPage = chapterMapper.selectPage( new Page<>(page, size), new LambdaQueryWrapper<Chapter>() .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结对查询的 N+1 问题:目录页不会因为查了 200 章就执行 200 次SELECT * FROM novel WHERE id = ?,而是一次查完。第二,分页和 join 的兼容问题:MyBatis-Plus 的分页插件在碰到自定义 join 时,count 查询经常会被解析错,尤其当 SQL 里出现DISTINCT或子查询时。拆开来写之后,分页查询的 SQL 变成了单表WHERE novel_id = ? AND status = 1 ORDER BY chapter_no,count 和 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 Result<ChapterContentVO> 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 LambdaQueryWrapper<Chapter>() .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, List<Chapter> 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()); List<Chapter> 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, List<Chapter> 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,不能是ALL;rows不应该超过一万。如果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 加一条端到端阅读链路,跑通再合代码。这套流程保证我被运营和读者夹在中间的时候,至少代码不会成为那个半夜响起来的报警电话。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询