做一个豆瓣风格的电子图书推荐系统,说起来简单,真要把"爬虫采数据—推荐算匹配—微服务扛并发—前端做体验"这条链路完整跑通,坑远比想象中多。尤其是当你选了SpringBoot+Vue+SpringCloud这套组合拳,还要强行上分布式,很多事情就不再是"写个接口"那么简单了。这篇文章不打算讲那种悬浮在PPT里的架构,而是把这套系统从零到落地过程中最容易被忽略、最容易翻车的环节,一个一个拆开聊。全文围绕微服务拆分、爬虫策略、分布式锁与幂等、推荐策略、前后端联调这几个核心板块展开,按我实际踩坑的顺序来讲。
1. 项目背景与整体技术选型:为什么是SpringCloud而不是"单机一把梭"
先交代一下我在做什么。这个项目的目标很直接:爬取豆瓣公开的图书条目数据,加工成结构化信息,构建电子图书库,然后基于用户行为做个性化推荐,最终以Web应用形式呈现,支持关键词检索、分类浏览、评分预测和热门榜单。听起来是个人人都能做的"图书管理系统",但要往上叠"微服务分布式"这顶帽子,整个设计思路就得换一遍。
1.1 单体应用在什么阶段开始卡脖子
很多人的第一版推荐系统就是一个SpringBoot单体项目,acontroller包天下,一个MySQL库扛所有。图书数据几万条还好,但一旦爬虫持续写入、用户行为日志不断累积、推荐计算要频繁读取全量特征,单体的脆弱点就暴露出来了:
- 爬虫模块和推荐模块耦合在一起,爬虫一旦卡住或反爬触发,整个用户请求链路都会被拖慢,甚至OOM。
- 搜索引擎和推荐引擎对资源的需求完全不同。搜书名是IO密集,算相似度是CPU密集,挤在同一个JVM里,互相干扰严重。
- 分布式锁、消息队列、异步任务这些机制,在单体里虽然也能实现,但代码边界很容易变成一团乱麻。
所以这个项目从一开始就定了基调:哪怕前期只有一台服务器,也要按微服务的思路去拆,为后续水平扩展留好余地。这也是很多面试官喜欢追问的点——"你为什么分那么多服务?"如果你能答出"爬虫写库和推荐计算解耦,避免IO与CPU互相抢占;用户行为采集独立成服务,方便后续上流式计算",这比背十遍微服务概念都有说服力。
1.2 技术栈选定的理由
整套系统的骨架如下:
| 模块 | 技术选型 | 承担的职责 |
|---|---|---|
| 服务注册与发现 | Nacos / Eureka | 服务实例注册、健康检查、配置中心 |
| 网关 | Spring Cloud Gateway | 统一鉴权、路由转发、限流 |
| 图书爬虫服务 | SpringBoot + HttpClient + Jsoup + XXL-Job | 抓取豆瓣图书数据、解析、清洗、落库 |
| 图书管理与搜索 | SpringBoot + Elasticsearch + MySQL | 图书元数据存储、全文检索 |
| 用户行为服务 | SpringBoot + Redis + Kafka(可选) | 收藏、评分、浏览历史采集 |
| 推荐服务 | SpringBoot + Python(TF-IDF/协同过滤) | 基于内容和协同过滤生成推荐列表 |
| 前端 | Vue 3 + Element Plus + ECharts | 用户端图书浏览、推荐展示、管理端数据看板 |
| 分布式基础设施 | Redis(分布式锁)+ RabbitMQ(异步解耦) | 缓存、消息通知、任务分发 |
选SpringCloud而不是Dubbo,核心原因是生态完整度。整个项目里有网关、有配置中心、有负载均衡、有熔断降级,SpringCloud全家桶配起来最省心;Dubbo强在RPC性能和治理,但网关层、配置中心这些还是要另配。做图书推荐这种偏业务型的系统,SpringCloud更顺手。
2. 微服务拆分的第一道坎:图书爬虫模块的独立设计与反爬对抗
爬虫是这个系统最脏最累的活,但也是最能体现工程能力的地方。很多人一听到"爬虫"就以为写个for循环套Jsoup就完事了,真去爬豆瓣图书页你会发现,豆瓣的反爬策略虽然不算最狠,但绝对够恶心:请求频率高了封你IP,请求头不对直接返回403,页面结构时不时微调,字段解析错位是家常便饭。
2.1 为什么要把爬虫单独拆成一个服务
我的做法是让爬虫服务完全独立于其他业务模块,只通过消息队列和数据库变更来"通知"下游。这样做的直接好处是:
- 爬虫任务可能出现长时间阻塞、超时甚至死循环,独立部署后最多挂掉自己,不影响用户正在用的搜索接口。
- 爬虫的数据写入往往是批量、高频的,如果直接写主业务库,会造成锁竞争;独立服务配合独立库表,可以把脏活隔离掉。
- 后续要加采集源或调整采集策略时,只改爬虫服务,不必重新发布推荐服务或网关。
还有一个隐藏优势:爬虫服务需要频繁迭代解析逻辑,每次改动都要快速验证,独立出去之后可以单独走一套发布流程,不用跟着整个微服务大版本走。
2.2 爬虫采集策略与解析的实操细节
采集链路是分层的,我把它做成四步流水线:
- 种子URL管理:把豆瓣图书分类页的URL作为种子,入库到
crawl_task表,每个任务带上优先级和状态字段。 - 请求调度:从任务表捞待抓取的任务,通过HttpClient发送请求。这里最关键的三个点是对User-Agent、Referer和Cookie的管理。
public class CrawlHttpClient { private static final String[] UA_POOL = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36" }; public static CloseableHttpClient buildClient() { return HttpClients.custom() .setUserAgent(UA_POOL[new Random().nextInt(UA_POOL.length)]) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(5000) .setSocketTimeout(10000) .build()) .setConnectionTimeToLive(30, TimeUnit.SECONDS) .build(); } }解析与清洗:用Jsoup解析HTML,抓住
#content里的书名、作者、出版社、出版年、ISBN、评分、评价人数等字段。解析时最容易出的问题是豆瓣偶尔会用不同结构的HTML渲染同一本书,比如有时作者区域是<div class="pub">,有时又是<span>。我的兜底方案是:先用选择器组合尝试,解析不到就降级为正则提取,再不行就丢弃该条并记录日志,后面人工补采。落库与消息通知:清洗后的数据通过MyBatis-Plus批量写入图书表,写入时用ISBN做唯一键做去重。写完发送一条MQ消息给搜索服务,通知它同步增量数据到Elasticsearch。
2.3 反爬三件套:限速、重试、代理
这一块属于"说破了不值钱,但不做必然翻车"的内容。
限速:豆瓣单IP的容忍度大约在每秒2-3次请求,超过了大概率触发验证。我给爬虫设置了令牌桶限流,核心是控制单任务抓取速率。
public class RateLimiter { private final Semaphore semaphore; private final int maxPermits; public RateLimiter(int maxPermits) { this.maxPermits = maxPermits; this.semaphore = new Semaphore(maxPermits); } public void acquire() throws InterruptedException { semaphore.acquire(); // 每秒放一个令牌,超过即等待 } public void release() { semaphore.release(); } }重试机制:对于403或5xx响应,不做盲目重试,而是采用指数退避策略。第一次等待2秒,第二次等待4秒,最高到30秒。连续失败超过5次就把任务标记为blocked状态,等下一轮巡检再处理。
代理池:真正要抓大批量数据时,单IP是扛不住的。我给爬虫服务预留了代理池接口,通过切换IP来分摊压力。使用代理时有一个细节:不是所有代理都支持HTTPS,先做连通性测试再放行,不然请求会一直卡在握手阶段。
整个爬虫模块的代码量占全项目的三成左右,但它本身不该有任何业务逻辑,它就是一个"数据搬用工"。你把这层的定位想清楚了,后面所有模块的接口设计都会清爽很多。
3. 推荐系统的冷启动设计与分布式环境下的数据一致性
图书推荐听起来是个"锦上添花"的功能,但在这个项目里它才是真正的核心价值。我调研过很多开源的推荐方案,最后没有引入Mahout或Spark MLlib,而是自己实现了轻量级的推荐算法,理由后面细说。
3.1 基于内容的推荐:TF-IDF向量化与余弦相似度
图书和图书之间的相似度,可以通过内容标签来计算。比如一本书的简介、标签、分类信息,分词后拼成一个文档,用TF-IDF把每本书变成向量,再算余弦相似度。这样当用户点击一本书时,系统可以把与它最相似的几本书推出来。
分词这块我用了HanLP,它在中文分词上的效果比直接按空格切分好太多。SpringBoot整合HanLP也很简单,引入依赖后加载词典,然后把每本书的关键字段拼接后丢给分词器。
public class BookVectorizer { public Map<String, Double> tokenize(String rawText) { List<String> terms = HanLP.segment(rawText).stream() .map(term -> term.word) .filter(word -> word.length() > 1) // 过滤单字 .collect(Collectors.toList()); // 计算TF Map<String, Double> tfMap = new HashMap<>(); // 这里就是遍历term计数,然后除以总词数 return tfMap; } }这一步的工程难点在于:几万本书两两计算相似度是O(n²)量级的操作,单机内存和时间都扛不住。我的优化思路是:
- 只对同一分类下的书做相似度计算,比如"小说"分类下算一次,"历史"分类下算一次,缩小计算域。
- 预先离线算好相似度矩阵,存入Redis,不搞实时计算。
- 用户点了一本书,直接去Redis取TopN相似图书,毫秒级返回。
3.2 协同过滤的取舍:为什么没用Spark MLlib
很多教程一上来就建议用Spark做协同过滤,但对于这个体量的项目,说实话有点杀鸡用牛刀。Spark集群起步就要三台机器,而且会极大增加部署和运维成本。我的做法是:
- 第一步用基于物品的协同过滤(ItemCF),统计用户对图书的评分矩阵,计算物品之间的共现矩阵,再按共现次数和评分加权得到推荐分数。
- 第二步用基于内容的推荐兜底,处理新用户没有行为数据的情况(冷启动)。
我封装了一个轻量级的推荐引擎,输入是用户行为日志表,输出是每个用户的TopN推荐列表,通过定时任务每天凌晨跑一次,结果写入Redis的recommend:user:{userId}键。
3.3 分布式环境下用户行为采集的一致性陷阱
推荐效果好不好,数据准确是关键。用户的行为数据分散在很多服务里:可能在图书详情页评分,可能在搜索结果里点击,还可能在收藏夹操作。这些数据如果各自为政,推荐模块拿到手就是一堆乱账。
所以项目里做了一个用户行为采集服务,所有行为通过统一的API上报,格式如下:
{ "userId": "10001", "bookId": "10087", "behaviorType": "score", "score": 8.5, "timestamp": 1715590400000 }行为数据先写Redis,再异步刷到MySQL的user_behavior表。这里必须强调一个点:如果使用Spring Cloud的OpenFeign异步上报,必须注意超时。之前我在采集服务里直接同步调推荐服务,结果推荐服务偶尔Full GC,导致采集接口超时,前端就报错了。后来改成:
- 采集接口只负责写Redis,不阻塞。
- 定时批量同步Redis到MySQL。
- 推荐服务只读MySQL中的行为表,离线计算。
这套异步链路跑起来之后,用户侧没再出现过因为推荐逻辑触发导致的接口超时。
4. SpringCloud核心组件落地:从注册中心到网关限流的配置细节
技术选型时可以讲大道理,但真正落地时每个组件的坑又会让你怀疑人生。这一节只讲实际配置过程中最容易出问题的地方。
4.1 Nacos注册中心:命名空间与服务分组
这个项目的服务注册用Nacos,版本是2.x。本地起单机Nacos很简单,startup.cmd -m standalone就能跑。但在微服务配置里,有几个细节需要特别留意:
- 命名空间(namespace):建议按环境划分,dev、test、prod各一个namespace,防止开发环境的服务注册到生产环境。
- 服务分组(group):默认是DEFAULT_GROUP,一般不用改,但如果你有多个团队共用一套Nacos,最好分组隔离。
- 临时实例(ephemeral):默认true,如果用K8s部署,服务实例的注册和摘除频率很高,临时实例反而更合适。
每个服务的bootstrap.yml大致长这样:
spring: application: name: book-recommend-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: d3800240-c2a3-4a1a-9d87-1b3f0a123456 config: server-addr: 127.0.0.1:8848 file-extension: yaml配置中心存的是公共配置,比如数据源、Redis连接、消息队列地址。这样做的好处是:线上配置改一处,全部服务自动刷新,不用逐台服务器去改配置文件。注意要用@RefreshScope来让配置在运行时生效。
4.2 Gateway网关:断言、过滤器与限流
网关在项目里承担三个职责:统一入口路由、Token鉴权、接口限流。路由配置核心如下:
spring: cloud: gateway: routes: - id: book-search-route uri: lb://book-search-service predicates: - Path=/api/search/** filters: - StripPrefix=1 - id: book-recommend-route uri: lb://book-recommend-service predicates: - Path=/api/recommend/** filters: - StripPrefix=1这里最容易踩的坑是StripPrefix=1和Path的配合。如果前端请求的是/api/search/books,经过StripPrefix后,后端服务收到的是/books,不匹配的controller路径就会直接404。我之前调试半天,最后用- StripPrefix=1加上后端controller映射统一修改才解决。
网关层做限流用的是RequestRateLimiter,需要自定义KeyResolver。这里有个小技巧:按用户ID限流比按IP限流更准确,因为校园网或公司网络下,大量用户共享出口IP,按IP限流容易误伤。
@Bean public KeyResolver userKeyResolver() { return exchange -> { String userId = exchange.getRequest().getHeaders().getFirst("userId"); return Mono.just(userId != null ? userId : "anonymous"); }; }4.3 OpenFeign调用:超时与熔断的合理设置
服务间调用我用了OpenFeign+Ribbon(SpringCloud 2020之后是Spring Cloud LoadBalancer)。配置超时时,千万别用默认值,默认的1秒超时在跨服务查询时几乎是必超时的。
ribbon: ReadTimeout: 5000 ConnectTimeout: 3000 feign: hystrix: enabled: true后来项目升级到SpringCloud 2021+,Hystrix进入了维护模式,我把熔断器换成了Sentinel。Sentinel的好处是可以在控制台实时看到每个接口的QPS、RT和异常比例,比Hystrix的可视化强不少。熔断规则我配合Nacos做了动态配置,不用重启服务就能调整阈值,这在线上排查问题时特别救命。
5. 分布式锁与数据幂等:那些你以为简单却翻车的并发场景
爬虫服务、用户行为服务和推荐服务之间,有很多并发写库的节点。刚开始我用的就是最朴素的做法:先查一遍数据库,不存在就插入。但分布式环境下,"先查再插"是典型的并发地雷。
5.1 爬虫服务中的重复数据问题
两条爬虫线程同时抓取同一本ISBN的图书,各自判断"数据库里没有这本书",然后双双执行insert,结果主键冲突或者产生重复数据。解决办法是给ISBN建唯一索引,然后利用数据库的唯一索引兜底:
ALTER TABLE book ADD UNIQUE KEY uk_isbn (isbn);这样即便并发插入,也只有一条能成功,另一条会报DuplicateKeyException,捕获后转为更新操作即可。这个方法比分布式锁更可靠,因为它是数据库本身保证的,不依赖Redis的可用性。
5.2 Redis分布式锁的正确打开方式
但有些场景不能只靠数据库唯一索引,比如"推荐任务缓存刷新"这种操作,多个服务节点同时执行会造成重复计算,浪费资源不说,还可能导致缓存数据不一致。这时就要上分布式锁。
我一开始看网上教程用的是SETNX命令配合expire,这个写法有个经典问题:如果SETNX执行成功后、还没执行expire时服务宕机,这把锁会永远不释放,后续所有任务都会被阻塞。
正确的姿势是用Redisson,它内部封装了看门狗机制,会自动给锁续期,在绝大多数场景下直接拿来用就好。
@Autowired private RedissonClient redissonClient; public void refreshRecommendCache(Long userId) { RLock lock = redissonClient.getLock("recommend:lock:" + userId); boolean isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!isLocked) { log.info("获取锁失败,其他节点正在处理"); return; } try { // 执行推荐计算和缓存刷新 } finally { lock.unlock(); } }关于这个锁的经验:锁的粒度一定要细。刚开始我用"recommend:lock:all"这把全局锁来锁所有用户的推荐刷新,结果执行一次要十几秒,期间其他用户刷新全部阻塞。后来把key改成按userId粒度,大幅降低了锁竞争。
5.3 幂等性设计的兜底策略
分布式锁能保证同一时刻只有一个节点在跑,但消息队列的重复投递问题仍然存在。爬虫定时任务通过RabbitMQ通知搜索服务增量更新,如果消息消费成功但确认失败,消息会被重新投递,搜索服务就会收到两条一模一样的更新通知。
我的幂等方案是:给每条消息生成一个业务唯一ID,Redis里存一个message:consumed:{msgId}的键,设置过期时间为24小时。消费端拿到消息先检查这个键,存在就直接返回,不存在才处理业务,并写入该键。
public void onMessage(BookUpdateMessage msg) { String msgKey = "message:consumed:" + msg.getMessageId(); Boolean first = redisTemplate.opsForValue() .setIfAbsent(msgKey, "1", Duration.ofHours(24)); if (Boolean.FALSE.equals(first)) { // 已消费过,直接丢弃 return; } // 真正执行增量同步 bookSearchService.syncBookToEs(msg.getBookId()); }这里用setIfAbsent原子操作,天然避免了并发下的重复执行问题,比"先查再插"靠谱得多。
6. 推荐策略背后的数据链路:从Elasticsearch到Redis缓存的实战配置
推荐系统跑起来之后,最影响用户体验的是响应速度。用户从点击"我的推荐"到页面渲染出图书卡片,整个过程必须控制在1秒以内。这背后考验的是数据链路的每一个环节。
6.1 Elasticsearch在图书搜索中的角色
图书检索这块我没有全盘依赖MySQL的like查询,而是引入了Elasticsearch做全文检索。原因很简单:当用户输入"三体",你要他能匹配到"三体"、"三体全集"、"三体X:观想之宙"等书名;当用户输入"东野圭吾",你要能匹配到该作者的所有作品。MySQL的like%三体%在数据量小的时候跑得还行,到了几十万条书目的量级,性能就会明显下降。
我用的是Elasticsearch 7.x版本,索引结构如下:
{ "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word" }, "author": { "type": "keyword" }, "isbn": { "type": "keyword" }, "summary": { "type": "text", "analyzer": "ik_max_word" }, "rating": { "type": "float" }, "category": { "type": "keyword" } } } }中文分词用了IK分词器,把ik_max_word和ik_smart区别讲清楚:ik_max_word切词更细,适合搜索召回;ik_smart切词更精确,适合搜索排序。索引场景用ik_max_word,查询场景用ik_smart,两套配合才能召回多而不乱。
SpringBoot整合Elasticsearch最经典的坑是版本不匹配。我用的是Spring Data Elasticsearch 4.x,它对应的ES客户端版本是7.x。如果你的SpringBoot版本和ES版本对不上,连接时会出现Unable to parse response for node之类的诡异报错。解决方法是引入和ES服务端版本一致的RestHighLevelClient。官方文档里虽然说着"不建议直接用RestHighLevelClient",但说实话,对于这种体量的项目,反而是最稳定的选择。
6.2 Redis缓存策略:防穿透与防雪崩
图书列表、推荐列表、分类榜单,这些都是高频读取低频率更新的数据。我用了"两级缓存模式":
- 第一级:本地Caffeine缓存,处理单机内高频访问。
- 第二级:Redis缓存,处理跨节点的共享缓存。
先从Redis查,查不到就从MySQL查,成功后再写Redis,并设置随机过期时间。随机过期时间很关键,如果一大批缓存键同时过期,MySQL会在瞬间被打爆,这就是缓存雪崩。代码里设置过期时间是base + random.nextInt(300)秒。
防止缓存穿透的逻辑是:如果MySQL里没有这本书,也写一个空值到Redis,过期时间设置短一点,比如60秒。这样下次请求同一个ID不会每次都打到MySQL。
6.3 推荐结果的组装与排序
从Redis拿到推荐图书ID列表后,还需要从ES或者MySQL捞出每本书的封面、作者、评分等字段,组装成前端需要的JSON。这个组装过程如果每次都实时查库,性能会很难看。我的做法是:把图书核心字段在推荐计算时就序列化好,直接存在推荐结果的缓存里,前端一次请求全部返回。
推荐的排序不只是按评分高低,还要叠加以下因素:
- 用户行为加权:用户浏览过的分类里的书,排序权重+1.2。
- 内容新鲜度:最近上架的图书,权重+1.5。
- 评分稀释:评价人数太少的书,评分乘以一个置信系数,防止冷门书靠少数高分霸榜。
排序逻辑全部在Java侧完成,通过实现自定义Comparator,灵活调整规则。
7. 前端Vue3与SpringCloud网关的对接细节
后端微服务再怎么拆,前端看到的仍然是一个统一的入口。Vue这边我用了Vue3 + Vite + Element Plus + Pinia,路由和状态管理都做了模块化拆分。但真正花时间调试的,是前后端跨域和鉴权这两个环节。
7.1 Vite开发代理与生产环境网关转发
开发环境下,Vite的vite.config.js需要代理所有/api请求到网关地址:
export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })生产环境下,前端打包成静态资源后,部署在Nginx里,Nginx再把/api请求反向代理到网关所在的服务地址。这里有一个必须注意的点:网关转发时会修改Host头,后端服务如果依赖request.getServerName()获取域名,可能会拿到内网IP。解决方法是网关层配置X-Forwarded-For和X-Forwarded-Host头转发。
7.2 JWT鉴权:从登录到网关过滤器的实现链路
系统里用户登录和token校验采用了JWT方案,无状态鉴权非常契合微服务分布式场景。用户在登录服务验证通过后,得到一个签名后的Token,里面包含userId和角色信息。前端在每次请求时通过Authorization请求头携带Token,网关会先解析Token,校验签名和过期时间,然后把userId写入请求头,转发给下游服务。
网关里的JWT校验过滤器是整个链路的安全防线,我遇到最多的两个问题是:
- 放行路径控制:登录注册接口必须放行,如果写在
/api/auth/**路径后面,注意在application.yml里配置白名单。 - 刷新Token逻辑:Token有2小时过期时间,如果用户在看书过程中Token过期了,前端必须自动刷新并重新发起请求。Vue这边我用Axios拦截器统一处理401响应。
service.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { // 刷新token或跳转登录 router.push('/login') } return Promise.reject(error) } )7.3 页面性能:图书封面懒加载与ECharts数据看板
图书列表页是图片密集型页面,几百本书同时加载的话,浏览器并发连接数会打满。我用了Vue的懒加载指令:
<img v-lazy="book.coverUrl" alt="封面" />主图加载完成前先显示一个骨架屏占位,这个交互细节很影响用户体感。
管理端的数据大屏我用ECharts绘制了图书分类分布饼图、评分趋势折线图、热门榜Top10条形图。这些数据来自后端单独的统计接口,由管理服务聚合各模块的数据后返回。统计接口的思路很简单:查询MySQL时用GROUP BY分组统计,结果Redis里缓存5分钟,前端轮询拉取。
8. 部署过程中最容易被忽视的环境问题与调优记录
本地开发一切正常,一上服务器就各种问题,这类情况在微服务项目里几乎无法避免。我在这套系统部署时踩过几个比较典型的坑,单独列出来供参考。
8.1 JDK版本与服务端兼容性
SpringBoot 2.7 + SpringCloud 2021.x 在JDK8上是完全没问题的,但我最初在本地用的是JDK17,编译和运行都很正常,结果部署到服务器上的JDK8环境时,直接报了UnsupportedClassVersionError。这类问题的排查很快,但会白白浪费很多时间。建议项目一开始就统一在.mvn配置里声明JDK版本:
<properties> <java.version>1.8</java.version> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties>8.2 Docker容器化部署:构建镜像时的网络与内存问题
微服务项目如果一个个手动启动,环境依赖很折磨人。我给这套系统写了完整的Docker Compose编排,把Nacos、MySQL、Redis、Elasticsearch、RabbitMQ、业务服务全部编排在一起。在构建后端服务镜像时有几个容易忽视的细节:
- Maven构建阶段和JRE运行阶段要分开,用多阶段构建把镜像体积从几百MB压到一百多MB。
- JVM内存参数必须显式设置,Docker容器内默认的
-XX:MaxRAMPercentage在不同JDK版本上表现不同,容易导致容器内存超限被干掉。
FROM eclipse-temurin:8-jre-alpine COPY target/book-recommend-service.jar app.jar ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]8.3 Sentinel控制台与分布式限流的落地
前面提到熔断降级用了Sentinel,这里补充一下如何把Sentinel限流规则持久化到Nacos。默认情况下,在Sentinel控制台上配置的规则是保存在内存里的,服务一重启规则就没了。要持久化,需要引入Sentinel的Nacos数据源扩展。
我配置好之后发现一个现象:限流有时候生效,有时候不生效。排查后发现,Sentinel限流的粒度是"资源"而不是"用户"。比如我限流规则配的是GET:/api/recommend这个资源,每秒QPS限50,那么50个用户同时请求会被拦下,但如果有人恶意刷接口,照样会打满50个QPS,其他正常用户的请求会被拒绝。优化思路是:把Token鉴权后的userId作为参数,用Sentinel的热点参数限流功能,按用户维度限流。
9. 这套系统的后劲:从豆瓣图书推荐扩展到通用领域
做完了豆瓣电子图书推荐系统后,我最大的体会是:微服务架构和推荐算法这两块其实是通用的,换一个数据源、换一套业务规则,骨架可以原封不动搬走。图书换成电影、音乐、课程,爬虫解析规则改一改,推荐特征换一换,系统照样跑。
具体来说,这套架构至少可以往三个方向延伸:
- 视频课程推荐:把"图书特征"换成"课程的学科分类、难度、讲师信息",协同过滤逻辑不变,就能给在线教育平台做个性化课程推荐。
- 新闻资讯推荐:用户行为采集模块直接复用,推荐模块调整特征权重,接入实时爬虫数据,可以做资讯类App的个性化动态。
- 商品推荐:加上"价格区间偏好"作为特征,把评价数据换成销量和库存数据,就能支撑电商场景的"猜你喜欢"。
这些扩展的价值在于:你投入在分布式锁、ES同步、推荐算法上的所有代码,都不是一次性代码,而是可以在多个业务领域复用的底层能力。
做这类系统还有一个容易忽略的运维要点——数据质量监控。爬虫模块偶尔会抓到残缺或错误的数据,如果不做清洗,推荐结果就会被污染。我给清洗环节增加了一套校验规则:书名和ISBN不能为空,评分范围必须在0-10之间,作者字段不能包含"佚名"等无效值。不合格的数据进入待处理队列,由管理后台人工确认或者后续补采。
10. 最后的几个细节提醒
从这套系统踩过的坑里,挑出几个最值得记住的,单独说一下:
第一,接口返回格式必须全局统一。项目里我用了Result<T>封装所有接口返回值,包含code、message和data字段。这个约定的价值在联调阶段体现得最充分,前后端不用为"这次接口返回的是数组还是对象"反复扯皮。
第二,日志链路追踪做起来。微服务一个请求会经过网关、搜索服务、推荐服务,排查问题如果不看TraceId,纯看时间戳根本对不上。我引入了Sleuth+Zipkin,每个请求生成一个TraceId贯穿全局。这个改造在开发期觉得烦,但一上线立刻真香。
第三,压测要趁早,别等上线前才做。我用JMeter对核心接口做了压测,网关限流阈值就是通过压测摸出来的。压测时要注意把数据库连接池、Redis连接池的参数一并调优,不然很可能最先撑不住的不是代码,而是连接池。
这套系统的代码量大约在两万行左右,对于个人开发者来说算是个不小的工程。从爬虫采集到微服务拆分,从推荐计算到前端可视化,每一层都有值得深挖的细节。做这种全栈分布式项目,最大的收获不是技术栈本身,而是建立了一种能力:把一个看似简单的业务需求,拆解成可持续扩展的工程方案。如果你也想做类似的实践项目,建议先别贪大,从单服务跑通数据链路,再逐步拆分微服务、引入分布式组件,每一步都要能说出"为什么这么做",这个项目才算真正做透了。