做小说在线阅读平台这件事我最深的体会是真正决定项目上限的往往不是花里胡哨的功能而是最基础的那套章节模块。标题里的“章节设计”四个字看起来只是建一张表、写几个接口实际做下来才发现它把正文存储、目录展示、付费解锁、连载更新、批量导入、缓存防刷全串在了一起。我手上这个内部代号0fk8ru45的小说阅读平台就是从ThinkPHP老版本迁到Laravel重构的重中之重的就是章节这一块。这篇文章把我在这次重构里关于章节设计的思路、表结构、接口逻辑、踩过的坑完整地捋一遍。正在做小说站、或者想把手头老PHP框架项目往Laravel迁的朋友可以参考一下。1. 章节模块的产品逻辑先想清楚要解决什么问题1.1 章节不只是“存正文”先泼一盆冷水如果只把章节理解成“小说正文存在数据库里的一张表”后面一定会在重构或者性能优化的某个节点返工。因为在一套完整的小说阅读平台里章节这个实体承载的东西远比想象中多。用户侧章节是阅读路径的最小单元。读者进入书详情页要看目录点某一章进入正文读完要点上一章、下一章继续读到一半要记录阅读进度读完了还可能去写评论、打赏。作者侧章节是内容发布和编辑的基本单位。作者要写新章、改旧章要调整卷章顺序要把章节从草稿改成发布状态。运营侧章节涉及更复杂的业务规则免费章节和付费章节怎么区分、新书连载到什么章节才算上架、章节被举报违规后怎么下架。数据侧章节是几乎所有聚合指标的源头一本书多少章、多少万字、最近更新时间都是从章节表聚合出来的。这些需求映射到设计上就意味着章节表不能只存一个标题加一段正文还要有业务序号、状态字段、字数统计、时间戳以及一整套和小说主表、用户购买表之间稳定的关联关系。我前期设计0fk8ru45这个项目的章节模块时第一步做的就是把所有使用章节的场景列出来再决定字段怎么设计。方案定了再写代码后面返工的成本会小很多。1.2 为什么选择ThinkPHP到Laravel的重构路线有人可能会奇怪标题里为什么同时出现ThinkPHP和Laravel一个项目怎么会用两套框架。实际情况是老项目当初用的是ThinkPHP 5线上跑了挺久但章节相关的代码已经比较乱了。乱在哪里第一章节查询散落各处。有直接在控制器里写Db::name的有在模板里拼接SQL的一个取目录的逻辑在三个地方各写了一遍。第二没有统一的缓存方案热门书更新时数据库压力很大。第三改表结构靠手工执行SQL多人协作时经常出现某个人本地改了表、线上没同步的情况。这些问题叠加起来继续在ThinkPHP上修修补补当然也能跑但每加一个功能都在还旧账。团队最后决定用Laravel重新整理一遍不是ThinkPHP不好而是需要一个更完整的数据层约束机制和更顺手的生态。Laravel带来的核心价值有这么几点Eloquent的模型关联非常适合小说、章节、购买记录这种天然带嵌套关系的实体Migration让我终于可以像写代码一样管理表结构Queue把批量导入章节这种耗时任务变得可控。这套重构本质上不是“换框架”而是借框架升级的机会把章节模块的数据模型和读写链路重新梳理了一遍。1.3 一条完整的数据流在设计章节模块之前我把涉及章节的数据流完整画了一遍。不用复杂图表直接按顺序描述内容生产环节。编辑或作者在后台发起导入常见方式是上传一个TXT文件后台程序按正则把文件切分成章节逐条写入章节表。写入的同时更新小说主表的总章节数、总字数、最后更新时间。缓存刷新环节。章节数据变化后小说详情页的目录缓存、书籍信息缓存都要跟着失效。我这里采用主动删除对应Redis key的方式让下一次请求重新从数据库加载并写回缓存。用户访问环节。用户打开目录接口先查Redis命中直接返回没有则查数据库目录列表回填缓存。点进正文接口先判断章节的免费/付费状态再判断用户是否有阅读权限通过后返回正文内容和下一章编号。阅读记录环节。用户翻页时会上报阅读进度存到用户阅读记录表下次进入书详情页时可以展示“继续阅读”的入口。付费解锁环节。用户支付成功后后台回调写购买记录再把章节权限相关缓存删除用户下次请求正文接口时就能拿到全文。整理完这条链路后面设计表结构和接口的时候每一环的边界就清楚了。章节状态在哪个环节变更、缓存要在哪个操作后失效全部有据可依。2. 章节数据模型表结构设计要能说出理由2.1 核心字段逐项拆解章节表是整套系统的地基我见过不少项目因为前期字段没想清楚后期各种别扭。下面是我在0fk8ru45项目里最终采用的章节表结构每个字段都说明一下为什么这么定。字段类型设计说明idbigint unsigned 自增主键纯粹的内部标识不参与业务排序novel_idbigint unsigned 索引所属小说ID几乎所有章节查询都带它chapter_noint章节的业务编号第1章是1第2章是2重排时用它titlevarchar(200)章节标题contentlongtext章节正文拆表方案里放在附属表word_countint章节字数用于展示和榜单统计statustinyint0下架/草稿1免费2付费sort_orderint同书内排序权重默认为chapter_no的倍数created_attimestamp入库时间updated_attimestamp最后修改时间同时作为乐观锁版本参考这里最想强调的就是id和chapter_no的区别。很多人把自增id当章节顺序用短期内没问题可一旦出现“第2章和第3章之间插入了一章”或者“某章被下架”的情况id顺序就和阅读顺序对不上了。chapter_no是业务上的“第几章”是给用户看的顺序id只是物理存储标识。上一章、下一章、目录排序全部围绕chapter_no来做不会乱。status字段也别小看。免费和付费是用两个数字表示还是用更多状态取决于平台规则。我这里预留了草稿和下架状态是为了给后台编辑功能留余地作者可以先存草稿确认后再发布违规章节可以直接下架而不用删除数据。多一个状态值几乎不占空间但业务上灵活很多。2.2 正文字段是留在原表还是拆出去这是章节表设计里争议最大的问题content放哪里。先给结论如果正文单章在2万字以内、书库总量在几万本级别留在同一张表也能跑但为了查询效率和后续扩展我更建议把章节拆成两层。方案一是全量放一张表。查询目录时如果写了select *content会被一起捞出来白白浪费IO。方案二是拆成chapters和chapter_contents两张表用chapter_id一对一关联。目录接口只查chapters表正文接口才查chapter_contents。方案三是更进一步把正文放到对象存储或者分布式文件系统数据库里存URL适合超大章节、需要防盗链、准备做CDN分发的场景。我最终选了方案二理由有两个。第一章节元数据和正文的生命周期完全一致删除章节时两个表一起删事务范围很小。第二绝大多数请求只关心章节的标题、字数、状态根本不需要正文拆开后天然避免了大数据字段拖慢查询。如果哪天真需要防爬或CDN分发再往方案三升级也不迟。2.3 索引与常见查询的对应关系表结构定了索引设计要跟着核心查询走。章节模块最常见的SQL也就那几条-- 目录查询 SELECT chapter_no, title, word_count, status FROM chapters WHERE novel_id ? AND status IN (1, 2) ORDER BY chapter_no ASC; -- 正文查询 SELECT content FROM chapter_contents WHERE chapter_id ?; -- 上一章 SELECT chapter_no, title FROM chapters WHERE novel_id ? AND chapter_no ? AND status IN (1, 2) ORDER BY chapter_no DESC LIMIT 1; -- 下一章 SELECT chapter_no, title FROM chapters WHERE novel_id ? AND chapter_no ? AND status IN (1, 2) ORDER BY chapter_no ASC LIMIT 1;对应的索引建议是chapters表建一个(novel_id, chapter_no, status)的复合索引覆盖上面四条查询的大部分场景。这里有一个新手常犯的错误把status放在复合索引最左边。status的值就那么两三种区分度极低放在前面会让索引效果大打折扣。区分度高的字段要尽量靠左这对MySQL和大部分主流关系型数据库都适用。chapter_contents表只靠主键或chapter_id唯一索引查询不需要额外设计。如果业务里经常按小说查正文可以在chapter_id上再加novel_id但实际场景很少用得到。2.4 数据量增长后的演进空间很多朋友上来就想分库分表老实说没必要。一本普通的小说几百到几千章一万本书撑死也就千万级章节记录字段又轻MySQL单表扛起来毫无压力。真正要提前做的是索引正确、缓存到位、大字段分离这三件事。真到了必须拆分的时候最简单的方式是按novel_id取模分表。比如拆成16张子表路由规则就是 chapter_table_index novel_id % 16。这样同一本书的所有章节永远落在同一张子表里目录、正文、上一章下一章的查询都不需要跨表合并。这个方案实现成本低对章节模块的接口影响很小是从单表走向分库分表时比较顺手的一条路。至于做不做取决于各自平台的量级别为了分而分那是给自己找麻烦。3. 阅读链路功能设计目录、正文、翻页与解锁3.1 目录接口怎么做才不慢目录是小说站点击量最大的接口之一因为每一本书页面打开都要拉一次整个目录。目录列表本身不大一本书几百章每条只要标题、章节号、状态、字数算下来也就几十KB非常适合整体缓存。我的实现是Redis里存一份目录的JSON数组key固定为catalog:{novel_id}里面按chapter_no排序好。用户在详情页打开目录时后端先查缓存命中直接返回没命中才去数据库查一遍回填缓存。这样目录接口的数据库压力几乎被消掉了。缓存失效的时机要格外注意。以前我用过期时间设了60秒但热门书更新很频繁读者在作者刚更新完的几分钟内反复刷目录很容易看到过期数据。后来改成主动失效任何章节新增、修改、上下架的操作结束后直接把对应novel_id的catalog缓存删掉。删除比更新简单可靠下一次请求自然会把新目录写回缓存还能顺便避免并发更新缓存时内容不一致的问题。3.2 上一章/下一章别用id加减1这个坑我当年真的踩过而且线上用户反馈了好几次“下一章跳错地方”我才注意到。表面上看上一章不就是当前id减1、下一章就是加1吗在小说的世界里这个逻辑完全不成立。举个实际例子。某本书原本第10章、第11章都在突然第10章被运营下架了用id加减1的读者在第9章点“下一章”直接跳到第11章这还算运气好。可如果第10章和第11章因为内容关联被同时下架读者会直接跳到第12章吗不会因为第11章之前的可见章节id是9点下一章查到的id是10而10不可见很多写法就直接返回空了阅读链路当场断掉。更常见的是作者修改章节顺序、插入卷章id顺序和阅读顺序再也对不上。正确做法是始终用chapter_no定位。查询上一章时条件是同一本novel_id下chapter_no小于当前值、状态可见倒序取第一条下一章则正好反过来。这样无论中间有多少章被下架、被插入跳转结果永远正确。边界情况也要处理第一章没有上一章最后一章没有下一章时接口返回空指引前端就展示“已经是第一章”或“等待更新”的文案。3.3 正文下发与付费试读的权限控制正文接口是章节模块里最敏感的地方权限设计错了付费章节等于白做。我的做法是只提供一个正文接口由后端根据章节状态和用户身份决定返回全文还是试读。收到请求后先查章节的status。status为1免费直接返回全文。status为2付费时继续判断用户是否登录、是否购买了本章。判断逻辑查user_paid_chapters表有记录就返回全文没有记录就返回正文前若干字符作为试读具体试读长度由后台配置一般是章节字数的10%到20%再加一个固定上限防止单章特别短时试读比例过高。这里有两个容易被忽略的点。第一付费章节的权限判断必须在服务端完成不能依赖前端传参否则会有人绕过按钮直接调接口。第二正文如果走了对象存储或者独立文件分发临时访问URL最好带签名和过期时间避免被直接抓走。平台早期正文放数据库时接口判断做对了就行等上了CDN签名URL的思路要提前设计进去。3.4 购买解锁与并发安全付费章节的解锁流程看似简单用户点购买、支付、回调写记录、再访问正文就通了。实际操作里最烦的是并发和缓存。用户点了两次购买支付网关回调也可能重复推送如果不加约束user_paid_chapters表里会出现两条重复记录后续统计、退款都会出错。我的处理是用一张唯一索引把novel_id、chapter_no、user_id三列设成唯一配合MySQL的insert on duplicate key ignore重复回调直接吞掉不会产生脏数据。解锁成功后还要记得删缓存。用户阅读权限如果做了缓存必须在回调完成后删除该用户的章节权限缓存不然用户明明买了下次请求还是试读体验就很差。权限缓存的粒度建议按用户章节来key形如paid:{user_id}:{chapter_id}值只要存在与否就够了数据量小排查也方便。4. ThinkPHP与Laravel落地对比迁移中的关键差异4.1 路由定义与控制器结构迁移到Laravel后第一个明显感受是路由变得清晰了。ThinkPHP 5默认是模块/控制器/方法这种三段式URL章节相关接口分散在多个控制器里URL语义不统一。Laravel用显式路由表接口长什么样完全自己决定。章节模块的路由我这样定义Route::get(/books/{novel}/catalog, [CatalogController::class, show]); Route::get(/books/{novel}/chapters/{chapterNo}, [ChapterController::class, show]); Route::post(/admin/novels/{novel}/chapters/import, [AdminChapterController::class, import]);大括号参数自动绑定chapterNo这种业务编号直接在控制器里取出不用再手写正则从URL里抠。对团队来说新成员看路由表就能知道平台有哪些接口比从前翻控制器目录高效得多。4.2 模型关联写法的差异老项目里写章节查询常见是用Db::name(chapters)-where(novel_id, $id)-select()表与表之间的关联在业务代码里手写join。Laravel的Eloquent可以把模型关系定义在模型类里写起来更像在描述业务。我定义了Novel模型和Chapter模型的关系之后获取目录可以简写成$chapterList $novel-chapters() -whereIn(status, [1, 2]) -orderBy(chapter_no) -get([chapter_no, title, word_count, status]);对于网站开发新手这种写法比手写join更容易理解也减少了SQL拼接出错的可能。注意一点关联方法里不要加载content大字段目录场景只需要元数据正文场景通过ChapterContent模型的关联单独加载否则Eloquent的with用法没控制好反而会把大字段带出来性能还不如手写SQL。4.3 迁移工具与团队协作迁移工具是我认为Laravel最值得夸的一点。之前ThinkPHP项目改表结构靠手工SQLA同事本地加了个字段B同事拉代码后忘了执行SQL线上接口直接报字段不存在。Laravel的Migration把表结构调整变成了代码变更跟着版本库一起走。举个例子要给章节表增加一个“是否免费首章”的标记字段流程是php artisan make:migration add_is_first_free_to_chapters_table生成的迁移文件里写Schema操作然后执行php artisan migrate。团队成员拉代码后只需要跑一遍这个命令本地表结构就和线上同步了。生产环境发布时把迁移命令纳入上线流程几乎不会再出现“谁的表缺字段”这种低级事故。4.4 队列与异步任务章节导入是典型的重任务。用户上传一个几万章的TXT如果同步处理请求会一直挂着超时是家常便饭。Laravel的Queue把任务扔到后台执行接口立刻返回“导入中”进度可以用任务状态查询体验好很多。我把整个导入流程拆成了两步第一步上传文件后生成一个导入批次任务第二步队列Worker逐章解析、写入、更新缓存。用到的是Laravel自带的Job类失败还能自动重试配合failed_jobs表可以排查失败原因。ThinkPHP当然也有类似方案但Laravel的生态更顺手不需要再额外接一堆组件。实际跑下来十万字的TXT解析成几百章再入库在正常的服务器上几十秒内完成中间不会卡住后台。5. 实战中踩过的坑问题与排查实录5.1 章节排序错乱的完整排查过程有次运营反馈某本书目录顺序乱了我花了几乎一个下午去查。表象是目录里第5章排到了第10章后面用户评论里已经有人骂了。第一反应去查数据库发现id顺序和chapter_no顺序不一致。原因是这本书是编辑手工录入的有几章在后台“插入到指定位置”时程序只是改了chapter_no但旧数据的sort_order没有跟着刷新而目录查询那时是按sort_order排序的于是顺序就乱了。最后解决方案是两件事一是查询排序统一改成chapter_nosort_order只作为二级排序权重二是写了个修复脚本扫描所有书把chapter_no按1、2、3递增重新刷一遍。从此时起凡是章节有增删改后台逻辑里第一步就是重排chapter_no并刷新目录缓存。这种基础顺序问题在小说平台里属于必须零容忍的用户体验直接受影响。5.2 列表接口被大字段拖慢一次压测发现目录接口平均响应时间将近800毫秒仔细分析慢查询日志SQL本身不慢慢的是每次查询都带着几万字符的content字段一起返回。原因很直接早期写代码图方便用了select *目录查询和正文查询共用同一个查询方法。优化方式是给查询方法分成两类目录/列表类查询显式指定返回字段绝不带content只有正文类查询才去关联chapter_contents表取正文。改完以后再压测目录接口掉到了90毫秒左右MySQL的IO压力也明显降下来。这个优化没有任何高深的地方纯粹是查询习惯问题但从这个案例可以看出来字段选择对数据库性能的影响比想象中大得多。5.3 并发编辑造成脏数据后台编辑章节这个场景我一开始没有做并发保护。等到有次运营和技术同时改一章内容互相覆盖才发现问题。A保存的是上午的版本B中午改完直接覆盖A下午又打开编辑页保存把B的内容冲掉了。后来在编辑表单里加了updated_at隐藏字段提交时SQL带上这个条件UPDATE chapters SET title ?, content ?, updated_at NOW() WHERE id ? AND updated_at ?如果影响行数为0说明这章在别人手上已经被改过了前端提示“章节已被他人修改请刷新后重试”。这算乐观锁最简单的实现方式成本极低但避免了大部分互相覆盖的惨剧。如果是重要章节比如VIP章节的批量调整事务行锁的组合也更稳妥不过对大多数团队乐观锁已经够用。5.4 缓存穿透与缓存击穿小说平台缓存最怕两类问题。一类是热门书刚更新完大量用户同时来刷目录缓存还没来得及重建请求全部打到数据库另一类是某章被爬虫或恶意脚本大量访问导致数据库压力骤增。我的处理方法是写缓存时加互斥锁。以目录缓存为例当多个请求同时发现缓存不存在时只允许一个请求去数据库查询并回填其他请求短暂等待后直接读缓存。实现上可以用Redis的setnx做一个简单的锁拿到锁的查库拿不到的自旋等待。正文接口同理。另一个实战经验是不要只依赖过期时间。热门书可以在发布新章节时主动预热相关缓存比如更新完第20章后顺手把目录缓存删掉再立刻重建一次这样用户来访问时缓存一直是热的。这套组合拳下来数据库压力就能稳很多。5.5 常见问题速查表把平时小组里遇到最多的问题整理一张表方便新人排查。现象可能原因解决方案目录顺序乱跳按id排序而非chapter_no全部改为按chapter_no排序上一章点了没反应id加减1或跳过了下架章节用chapter_no状态条件查询付费章节能白看到全文前端隐藏接口、后端没校验权限正文接口服务端统一判断后台导入大TXT卡死同步处理改为队列异步处理更新章节后用户端没变化目录缓存未删除增删改后主动删catalog缓存接口响应突然变慢查询带了content大字段列表类查询显式指定字段用户买完还是试读权限缓存没清支付回调后删用户权限缓存多人编辑互相覆盖无并发控制加updated_at乐观锁这张表不是标准答案但把项目里真实发生过的坑集中放一起复盘价值很高。最后说一点我自己的体会。章节设计这个活技术含量不体现在某一段代码写得有多炫而体现在你有没有把整条阅读链路想清楚。表结构、排序字段、权限校验、缓存策略单独拆开看都是很基础的东西但组合在一起就是平台的核心体验。如果从头再来一次我会把两个事情放到最高优先级第一是chapter_no这套业务排序从第一天就严格推行所有查询围绕它来第二是缓存做好主动失效别指望过期时间兜底。希望这篇东西对正在做或者准备做小说阅读平台的朋友有点帮助也欢迎你有自己的实战经验来一起聊。
阅读完成 · 觉得有帮助?