
一个读书打卡小程序业务上看起来很简单用户登录、书架放书、点开章节阅读、读完打卡、拿积分、看排行榜。但真要把用户量、多端、持续迭代这些因素放在一起考虑单体应用很快就会让你难受。这篇就聊聊我做的书洞在线阅读打卡系统从单体到微服务的一次完整落地复盘。项目全套技术栈是 SpringBoot Vue SpringCloud 微服务 小程序C 端用户用微信小程序管理后台用 Vue后端拆了五个服务跑在 SpringCloud Alibaba 体系下。如果你正在纠结读书这种业务要不要上微服务微服务怎么拆比较合理Vue 后台和小程序双端怎么共享一套后端这篇应该能给你不少可以抄作业的参考。1. 为什么一个读书打卡系统非要拆微服务先回答一个肯定会被问的问题读书打卡系统的业务量级真的需要微服务吗我的答案是看情况但至少书洞这种形态的项目拆了之后利大于弊。1.1 项目最初的业务形态书洞不是一个纯粹的工具类阅读器它带有社区属性。用户进来之后要做的事包括浏览书城、搜索图书、把书加入书架、打开章节在线阅读、记录阅读进度、完成每日阅读打卡、查看连续打卡天数、参与积分排行管理员还需要在后台录入图书、管理章节、审核评论、查看运营数据。这个形态决定了两件事。第一它天然有多个业务域用户、图书、阅读、打卡、积分/排行、后台管理。第二它有一个相对低频但重要的 C 端入口——微信小程序还有一个高频操作场景——阅读器的进度上报和打卡。最开始我用的是单体内置模块划分一个 SpringBoot 工程包名按 user / book / reading / checkin / points 区分跑起来完全没问题。后来之所以拆是因为几个真实痛点。1.2 单体遇到的实际压力第一个痛点是阅读进度上报。用户在阅读器里每翻一页、每停留几秒客户端都会上报一次阅读进度。这个接口在设计上需要更新最近阅读位置、计算阅读时长、同步服务端书签高峰期单个用户一分钟能产生几十次请求。放在单体里这些请求会和打卡、积分、书城列表混在同一个进程里。某次做运营活动书城首页的聚合查询因为一个慢 SQL 把数据库连接池占满结果阅读进度上报也跟着超时整个阅读体验直接崩掉。第二个痛点是发版耦合。打卡规则是运营强依赖的每隔一阵就要调规则参数比如连续打卡多少天给额外积分、是否允许补卡、补卡消耗多少积分。这些改动虽然不大但每次都得跟着图书、阅读模块一起发版回归测试范围越来越大。第三个痛点是团队协作。后面项目加了两个人一个人管书城和图书录入一个人管打卡和积分玩法再算上小程序端。代码都在同一个工程里的时候合并冲突、互相等待发版是常态。拆服务本质上是把发布节奏也拆开了。1.3 微服务不等于拆了就完事这里必须泼一盆冷水微服务是有成本的。服务拆分之后你首先要面对的是分布式事务、跨服务调用、分布式链路追踪、网关统一鉴权、多环境配置管理这一堆东西如果业务只有几千个用户完全是负担。所以我的建议是如果只是为了毕设演示或者小规模练手单模块照样能跑得很顺但如果目标是做一个能持续迭代、允许各业务域独立扩展的项目书洞这种形态值得做微服务。关键是拆要有章法不能一拍脑袋按代码层拆而是按业务边界拆。下一节就是我的实际拆分记录。2. 服务边界怎么划五服务一网关的拆分逻辑服务拆分这个环节我踩过不少坑最后沉淀下来的方案是五个业务服务加一个网关。每个服务的命名和职责边界我列一张表来说明这张表花了我们不少时间才定下来。服务名核心职责主要接口示例独立数据库user-service用户注册登录、微信授权绑定、用户资料/api/user/login、/api/user/profile有book-service图书管理、章节管理、书城搜索、书架/api/book/list、/api/book/chapter有reading-service阅读进度上报、书签、阅读时长统计/api/reading/progress、/api/reading/bookmark有checkin-service打卡签到、连续打卡计算、补卡/api/checkin/today、/api/checkin/records有point-service积分流水、积分兑换、排行榜/api/point/detail、/api/point/rank有gateway统一入口、路由转发、鉴权、限流所有请求入口无2.1 按业务域拆分的最终清单先说说为什么是这五个而不是更多。图书和阅读要不要拆我纠结了很久。图书章节是静态内容阅读进度是高频动态数据两者虽然有关联但读接口的访问频率和写入模式完全不同。图书服务以查为主适合做缓存阅读服务以写为主必须保证性能和幂等。拆开之后阅读服务的压力再大也不会拖垮书城浏览这一点在运营活动期间得到了验证。打卡和积分为什么不合成一个服务最初的方案里打卡和积分确实是一个服务因为打卡动作必然产生积分。后来拆开的原因是积分玩法越来越多除了打卡阅读时长、分享邀请、评论被点赞都会加积分。积分服务变成了一个通用的账户流水系统打卡服务只需要在打卡成功后发一条 MQ 消息通知积分服务即可。用户服务是必须独立的因为它被所有服务依赖。所有服务都通过网关下发的 JWT 拿到用户标识不从数据库去查用户表用户详情再通过 Feign 调用 user-service 获取。2.2 每个服务的数据边界这也是拆分中容易翻车的地方。我见过很多人拆了服务数据库没拆最后还是共享一张用户表服务之间直接连对方的库等于白拆。书洞的做法是每个服务拥有自己独立的数据库服务之间禁止跨库查询。没有用户服务的数据怎么办通过服务间调用来拿或者使用同步到本地缓存的方式。图书服务需要展示作者信息就调用 user-service 的接口批量查询作者昵称和头像查询结果放进本地 Caffeine 缓存五分钟过期。这样做的好处是服务无状态扩容的时候想加实例就加实例不用考虑数据绑定。拆库之后带来的第一个问题是跨服务查询变慢原来一个 join 能查出来的数据现在要两三次调用。我的应对策略是面向查询建表业务查询力度的数据尽量在各自服务里做冗余。比如书架数据虽然图书详情在 book-service但用户的书架列表我直接在 book-service 里建了一张 shelf 表字段冗余了书名和封面 URL。打卡记录里冗余了用户昵称和积分值。冗余增加了入库时的维护成本但换来了查询链路的大幅简化。2.3 服务间调用链与一致性取舍服务间调用通过 OpenFeign统一走内部调用 token。调用链上最典型的场景是用户打卡小程序请求打卡接口 → gateway 鉴权转发 → checkin-service 处理打卡 → 发 MQ 消息 → point-service 更新积分和排行。这个过程存在分布式一致性问题打卡成功了但积分更新失败怎么办我的方案是不强求强一致。打卡和积分之间的延迟是允许的积分服务消费 MQ 消息后写积分流水如果失败则进行重试重试超过五次进死信队列人工补偿。这个方案在项目里运行下来实际积压和失败率都极低因为场景本身不涉及资金用户晚几秒看到积分变动完全无感。分布式事务我用了 Seata但只用在真正需要强一致的两个场景用户下单兑换实体书抵扣积分、管理员批量上下架图书。其余场景一律用本地消息表加 MQ 的最终一致性方案。这里多说一句能不用分布式事务就别用它带来的性能损耗和复杂性远超大多数业务场景本身的收益。3. 核心链路在线阅读打卡闭环的实现细节拆完服务最重要的就是把业务跑通。书洞有两个核心闭环一个是找书→看书→记进度的阅读闭环一个是阅读→打卡→积分→排行→激励继续阅读的打卡闭环。这部分我把表结构和关键实现思路都梳理出来。3.1 图书、章节与进度三张表的设计图书和章节这两张表在 book-service 里设计上要考虑在线阅读的特殊性。图书表的核心字段包括book_id、title、author_id、cover_url、description、category_id、word_count、status、create_time。Word_count 是从所有章节字数累加出来的它有两个用途书城列表展示规模以及打卡完成后计算积分的基础。章节表的核心字段是chapter_id、book_id、chapter_no、title、content、word_count、is_free。Content 字段存的是纯文本不是富文本。在线读阅读器用的是分段加载的方式接口返回章节文本客户端按屏幕尺寸分页渲染。纯文本的好处是体积小、解析快小程序端不需要引入额外的富文本解析组件渲染性能会好很多。阅读进度表在 reading-service 里字段包括user_id、book_id、chapter_id、read_time、position、update_time。这里有一个设计亮点进度表用user_id book_id做唯一索引用户每翻一章就 upsert 一次。为了减少写入压力客户端会做本地节流同一本书的进度上报间隔至少 5 秒翻页过程中的中间位置只存本地缓存服务端保存的是当前章节的阅读位置和最后阅读时间。3.2 阅读器功能点进度、书签、字号、深色模式阅读器是小程序端最核心的页面功能看起来简单做起来细节很多。进度恢复的流程是进入书籍详情页时小程序先请求 reading-service 的进度接口返回最后阅读的章节 ID 和位置然后跳转到对应的章节和位置。如果没有历史进度默认从第一章开始。这个接口必须在书籍详情页就调用不能等到阅读器加载完再调否则会出现明显的白屏等待。书签功能单独一张表支持两种书签手动书签和系统书签。手动书签是用户主动打的字段包括book_id、chapter_id、content_preview、position系统书签是上次阅读到这里的标记本质上就是阅读进度。手动书签列表页会展示一段内容预览方便用户回忆这一段讲的是什么。字号和深色模式的设置我放在本地 storage不存服务端。原因很简单这是个人偏好不是跨设备强需求存服务端会增加接口调用和数据冗余。但如果你后续要做用户换设备同步阅读设置再考虑服务端存储。还有一个容易被忽略的点是目录加载。书章节多的书可能有两三百章一次全量返回会明显拖慢加载速度。我的方案是分两级加载先加载前 50 章滚动到底部再加载后续章节。目录接口返回轻量字段只有章节 ID、序号、标题不返回内容。3.3 打卡规则与防重复提交打卡是书洞的命脉功能规则要灵活实现要可靠。我的打卡规则存在 checkin 服务的数据库配置表里支持运营动态调整不用发版。基础规则是这样的用户每天可以打一次卡打卡条件是当天阅读时长超过 15 分钟。当天是否已打卡通过user_id checkin_date唯一索引保证。连续打卡天数的计算逻辑是查询用户最近的打卡记录从最新一天往前数中断则重新计算。这里我专门建了一张用户打卡统计表包含user_id、continuous_days、total_days、last_checkin_date在打卡成功后同步更新查询排行榜时直接从这张表拿数据不用实时跑全量计算。防重复提交是打卡接口的重中之重。小程序端做了按钮置灰但服务端必须再做一层保证。我在 Redis 里存了checkin:user:{userId}:date的 key执行打卡时先用 Redis 的 SETNX 命令抢占抢不到就直接返回今日已打卡。Redis 设置过期时间为当天 24 点。设置成功后再执行数据库事务事务成功就把 Redis 的值设为 done失败则删除 key 允许重试。这套逻辑下来我在并发测试里用 100 个线程同时打卡同一个用户最终也只有一条记录入库。3.4 弱化打卡KPI积分与阅读深度结合的玩法纯粹的打卡次数统计很容易变成刷数据的行为。用户每天打开 App 打卡一分钟就走对阅读平台没有任何价值。所以我在积分体系上做了层次设计。积分来源有四个每日阅读满 15 分钟打卡、连续打卡额外奖励、阅读时长累计奖励、书评获得赞同。其中阅读时长奖励的设计比较有意思按天统计用户阅读时长达到 30 分钟、60 分钟、120 分钟分别奖励不同积分。这样用户即使完成打卡也会愿意继续读下去。排行体系用的是阅读时长和连续打卡天数双维度。周排行榜按当周阅读时长排序榜单数据由 point-service 统计。为了支撑排行榜实时性我用 Redis 的 ZSet 存储用户周阅读时长每次阅读时长上报时同步累加对应 member 的 score。查询排行榜就是一个 ZREVRANGE 操作性能非常高。连续打卡排行则展示 top50数据来自打卡统计表的查询。4. SpringCloud基座Nacos、Gateway与统一鉴权微服务架构里的基础设施部分统一用 SpringCloud Alibaba 这套生态。选型理由后面详细说这里先给一张整体组件清单Nacos 做注册中心和配置中心Spring Cloud Gateway 做网关OpenFeign 做服务间调用Sentinel 做限流Seata 只在核心强一致场景用。4.1 选型为什么走SpringCloud Alibaba生态这是一个有争议的选型我直接说我的理由。SpringCloud 官方后来维护的几个组件里Eureka 已经进入维护模式Zuul 也基本不再有大的更新。SpringCloud Alibaba 提供了完整的注册、配置、限流、事务解决方案而且和 SpringBoot 的版本兼容性做得很好功能上不需要拼凑太多第三方件。注册中心和配置中心直接用 Nacos 一个组件搞定。Eureka 只解决服务发现配置管理还得搭 Config Server 加 Bus链路长不说部署也麻烦。Nacos 一个控制台既能看服务健康状态又能改配置并自动刷新省了很多事。但如果你项目里没有人熟悉 Nacos用 SpringCloud 官方生态也没问题。两者本质没有谁绝对优于谁关键看团队熟悉度和周边组件完整度。4.2 网关层鉴权C端小程序和B端后台一个网关搞定所有外部请求统一从网关进入。网关这里做了三件事路由转发、统一鉴权、限流。路由规则是按路径前缀匹配的/api/user/**转发到 user-service、/api/book/**转发到 book-service、/api/reading/**转发到 reading-service、/api/checkin/**转发到 checkin-service、/api/point/**转发到 point-service/api/admin/**转发到 admin-service。统一鉴权用的是 JWT。小程序用户登录时user-service 验证微信 code 并签发 JWT后续所有请求在 Authorization 头里携带。网关的 GlobalFilter 会先校验 JWT 的签名和过期时间——注意是校验签名不解析业务数据——校验通过后把用户 ID 放到请求头里透传给下游服务。下游服务只认经过网关的请求内部调用则通过专门的内部 token 做区分。后台管理端单独做了 admin-service和用户服务隔离。管理员体系和小程序用户体系互不相干。网关通过路径里的/api/admin前缀识别后台请求校验管理员身份的 JWT普通用户的 JWT 在里面是无效的。这里还有一个容易踩的坑网关超时时间。我之前设置过网关转发超时 10 秒但某个服务处理图片上传时耗时较长经常报网关超时。后来把全局超时调整成 30 秒同时服务内对长任务一律改成异步处理不要指望同步长连接能一直撑住。4.3 配置中心与多环境管理配置文件的管理在微服务架构里很容易失控。书洞的做法是每个服务只保留bootstrap.yml里面写 Nacos 地址、命名空间、配置文件后缀。业务配置全部放到 Nacos 配置中心拆成user-service.yml、common-db.yml这种粒度。多环境管理靠 Nacos 命名空间区分dev 命名空间、test 命名空间、prod 命名空间。每个命名空间里都有独立的服务配置代码从 Git 仓库拉下来之后只需要改bootstrap.yml里的命名空间 ID就能在本地连上对应的环境。这个机制让我在本地开发时不用把生产数据库连接写在代码里安全性也好一些。配置中心有一个关键经验涉及数据库连接、Redis 密码、第三方密钥的配置一定要放到 Nacos 并加上权限管控千万别写死在服务的本地配置里。我见过有人把腾讯云短信密钥提交到 Git 仓库然后被扫描爬虫拉走的那是真正的安全事故。5. Vue管理后台与微信小程序的双端适配后端架构搭好之后要面对两个前端端口的开发和适配。管理后台是 Vue Element PlusC 端是原生微信小程序 少量扩展组件。双端共用一套后端接口但有很多适配细节需要处理。5.1 管理后台图书录入、章节编辑、打卡报表Vue 管理后台是运营和内容管理者的主要操作入口核心页面有六个数据看板、图书列表、图书录入、章节管理、打卡记录、积分设置。图书录入是一个比较重的表单包含封面图片上传、分类选择、图书简介、收费策略等字段。封面图片上传走的是独立的文件上传接口上传完成后返回 URL和其他表单字段一起提交。图片处理上我在后端做了缩略图生成列表页用小图详情页用大图避免加载太慢。章节管理是内容编辑的重活儿。一个编辑需要能批量导入章节文本而不是一章一章手工录入。我开发了批量导入功能支持 Markdown 格式的章节包一键导入后端解析生成章节记录。导入成功之后自动更新图书的字数和章节数并给用户下发新章节的订阅消息。打卡报表是运营关注的核心数据。报表页提供按日期的打卡人数趋势图、连续打卡天数分布、积分发放明细。这些数据由 checkin-service 和 point-service 提供聚合接口。为了减少对数据库的压力日报表每天凌晨由定时任务汇总到一张统计表运营白天看报表走的是统计表查询不实时扫描流水表。5.2 小程序端登录、书架、阅读器与订阅消息小程序端用原生实现没有引入 uniapp。原因有两个阅读器对性能要求较高原生组件栈的可控性最强项目团队本身对原生小程序开发最熟悉。登录流程是微信小程序标准的 code 换 session 逻辑。小程序调用wx.login拿到 code发送给后端 user-service后端通过微信接口换取 openid如果用户不存在则自动注册。注册时生成默认昵称和头像用户可以后续在个人中心修改。书架是用户最常用的页面。书架数据来自 book-service 的 shelf 接口包含近期阅读的书籍列表按最后阅读时间排序。书架页要处理下拉刷新因为用户可能在别的设备读过书需要重新拉取同步。这里我实现了懒刷新页面 onShow 时先展示本地缓存的书架数据同时后台拉取最新数据有新数据再替换。这样用户每次进入书架都不用等白屏。订阅消息是小程序的重要触达方式。用户完成一次阅读打卡后后端可以推送次日打卡提醒的订阅消息。订阅消息需要在用户授权后调用wx.requestSubscribeMessage获取一次性订阅权限然后存入后端。后端在每日晚 8 点扫描当天未打卡且有授权记录的用户通过服务端调用下发提醒。这个功能上线后日均打卡率提升了大约一成运营价值很明显。5.3 本地联调抓包定位接口问题的经验小程序开发有一个天然痛点真机访问的接口地址和 PC 端不同。当用户在真机上反馈我的书架是空的打卡没反应时问题可能出在后端、网络代理、用户状态等多个环节这时候就需要抓包辅助定位。我的经验是用 Charles 搭配真机进行 HTTPS 抓包联调。具体做法是手机和 Mac 连同一个局域网手机 WiFi 代理指向 Mac 的 IP 和 Charles 端口。Charles 开启 SSL Proxying并安装配置自己的 CA 证书到手机上。这样就能在 PC 上看到小程序发出去的所有请求、请求头、参数和响应结果。抓包有一个非常实用的场景小程序端报错但后端日志没有明显异常时用抓包看一下实际发出去了什么参数。常见问题包括Authorization头没带、请求体是 JSON 字符串但后端接口需要的字段名不一致、鉴权 token 过期但小程序没跳转登录页。这些通过抓包一眼就能看出来不用反复在后端加日志调试。顺带提醒一句抓包工具用于自己开发的接口调试完全没问题但不要拿它去抓取他人小程序的商业接口数据。调试自家服务端接口时不要绕过 HTTPS 证书校验确保数据链路安全可靠。6. 上线前实测最容易翻车的四个点项目走到这里功能基本齐全了但在压测和试运行阶段翻了几个跟头。这一节挑四个最典型的翻车点说一下纯属实战经验。6.1 并发打卡时的幂等上面设计打卡流程的时候我提过 Redis SETNX这里再补一个压测案例。我在没有 SETNX 的情况下做过一次模拟100 个用户同时打卡各发一次请求数据库层面靠唯一索引挡住了重复数据但接口层会抛出DuplicateKeyException导致用户看到打卡失败的报错实际上数据已经写进去了。用户再点一次又提示成功这个体验非常差。加 SETNX 之后再压测90% 的请求在 Redis 层面就被拦截只有第一个请求能真正走到数据库事务。这里还有一个隐藏细节如果打卡成功但 Redis key 忘记设置过期时间这个 key 就会永远存在导致用户第二天无法打卡。所以我专门写了一个定时任务每天凌晨清理前一天遗留的打卡 key防止边界时间出问题。6.2 图书文件存储选型图书的文件形态五花八门有的是整本 PDF有的是拆分好的章节文本还有的是带音频的作者原声朗读。这些都不能直接塞进 MySQL我用 MinIO 做自建对象存储部署在内部服务器上。PDF 源文件存 MinIO需要在线阅读时后台程序解析 PDF 并抽取文本内容转换为章节文本存 MySQL。音频文件直接存 MinIO小程序端播放时通过 MinIO 提供的带签名 URL 访问。这里要说一个问题小程序对音频源地址有域名白名单限制所有音频和封面图的域名都必须在小程序后台配置到合法域名列表里否则真机上什么都加载不出来。我在第一次联调时踩了这个坑小程序开发者工具里正常真机上音频一直加载失败后来才发现是域名白名单漏配了。6.3 榜单热数据的缓存策略排行榜接口是整个系统里查询压力最大的接口之一。用户打开打卡页会看到当前排行榜点进详情页还会触发排行详情查询。如果没有缓存每次查询都要从 ZSet 上计算并组装用户信息。我的策略是排行榜原始数据用 Redis ZSet 维护最终返回给客户端的榜单结果再缓存一层。缓存 key 是rank:{type}:{date}过期时间 60 秒。用户看到的信息可以容忍最多 60 秒的延迟60 秒后自动刷新。这样一来ZSet 的读操作被大幅削减高峰期也能稳如老狗。另外榜单接口要考虑降级。如果 Redis 挂了接口直接返回缓存里的最后一次结果并记录降级日志。虽然 60 秒的缓存本身也能扛住 Redis 短暂不可用的场景但彻底挂掉 Redis 属于极端情况降级兜底还是得有。6.4 小程序包体积与启动性能微信小程序主包限制 2MB超过就不能发布。书洞的代码量其实不大但引入的 UI 组件库和图片资源很容易把包体积顶上去。我的处理方式是主包只放 tabBar 相关的页面阅读器、书架详情、排行榜等次要页面全部通过分包加载图片资源不放包内全部走对象存储的 CDN 地址组件库按需引入而不是全量导入。启动性能上还有一个小技巧小程序冷启动时会先请求用户信息和书架数据这两个请求如果有依赖关系就串行浪费很多时间。我把用户信息存在本地缓存冷启动直接读取本地书架数据并行请求数据到达后对比时间戳再决定是否渲染。实测启动时间从 1.8 秒降到了 1 秒以内体感明显。回到开头那个问题读书打卡系统需不需要微服务如果你想要的是一个能持续迭代、各业务域独立扩展、扛得住运营活动的项目答案是值得。但一定要清楚微服务不是免费的架构红利它换来的是更高的上限代价是更多的运维和开发成本。根据我个人实际操作下来的体会拆服务之前先把业务边界画清楚比选什么组件、用什么框架重要得多。业务边界画清楚了服务划分、数据库拆分、接口设计都会顺理成章边界画不清楚后续每个跨服务需求都会让你痛苦。最后再分享一个小技巧如果团队里有人第一次接触微服务先别急着把所有服务都接上 Nacos 和 Sentinel跑通最简单的两个服务之间的调用再逐步增加基础设施学习曲线会平滑很多。