ARTICLE DETAIL

资讯详情

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

SpringCloud微服务实战:仿下厨房后端架构与避坑指南

SpringCloud微服务实战:仿下厨房后端架构与避坑指南 简介本资源为基于Spring Cloud的仿下厨房系统后端完整源码面向具备Java与微服务基础、希望深入理解服务拆分与网关治理的开发者。项目采用多模块架构下辖网关、后台管理系统及商城、菜谱等三个后端服务网关动态路由由Nacos统一管理配置文件为gateway.config.json并区分dev与pro两套环境便于开发与生产切换。压缩包共534个文件约2.16MB其中156个java源文件承载核心业务逻辑28个xml与19个yml负责依赖及服务配置另有大量svg、png、js、html、css等前端静态资源以及properties、json等配置与说明文件结构清晰、便于按模块检索。目前已有186人学习下载。读者可借此掌握Spring Cloud微服务拆分、Nacos配置管理、动态路由与多环境部署等关键实现适合作为课程设计、毕业设计或微服务练手项目的参考方案。1. 从「仿下厨房」说起一个 SpringCloud 后端到底要解决什么问题很多人第一次看到「基于 SpringCloud 的仿下厨房系统后端」这个标题第一反应是「又是一个 CRUD 练手项目」。但真把下厨房这类菜谱社区的业务摊开看你会发现它一点都不简单用户上传菜谱图文混排、步骤分片、菜谱被收藏和点赞、按食材/标签/热度多维检索、关注关系与动态流、评论盖楼、以及后台的内容审核。这些场景里读多写少、热点数据集中、跨服务调用频繁恰好是 SpringCloud 微服务这套东西最能体现价值的地方。这篇笔记面向两类人一类是刚学完 SpringBoot、想找一个「业务真实、技术栈完整」的项目把微服务落地一遍的新手另一类是做过单体、想搞清楚服务拆分边界和分布式坑在哪的熟手。我会按「服务怎么拆 → 每个服务怎么写 → 跨服务怎么调 → 坑在哪」的顺序讲参数和命令都给到能直接抄的程度。SpringCloud 入门看着简洁真正难的是拆完之后那一堆分布式问题这才是这篇要讲透的部分。2. 服务拆分与工程骨架仿下厨房的 6 个微服务怎么切2.1 按业务域拆而不是按技术层拆新手最容易犯的错是按 controller/service/dao 拆服务结果每个服务都成了半成品。仿下厨房这种内容社区正确的切法是按业务域DDD 的限界上下文思路来切。我一般会拆成这几个服务名职责核心表gateway-service统一入口、鉴权、限流无user-service注册登录、用户资料、关注关系user、followrecipe-service菜谱发布、编辑、详情、步骤recipe、recipe_stepsearch-service菜谱检索、标签/食材聚合走 ES 索引interaction-service点赞、收藏、评论like、collect、commentadmin-service内容审核、举报处理audit、report拆分的判断标准就一条会不会被不同节奏地修改、会不会有独立的扩容需求。菜谱详情是读热点检索是计算密集互动是写热点这三者扩容策略完全不同所以必须分开。用户和关注关系放一起是因为它们的事务边界天然耦合。2.2 用 Maven 多模块搭骨架父工程统一管版本这是避免 SpringCloud 版本地狱的第一道防线。SpringCloud 和 SpringBoot 的版本必须严格对应错一个 patch 版本就可能启动报NoSuchMethodError。!-- 父 pom.xml 关键片段 -- properties java.version17/java.version spring-boot.version3.2.5/spring-boot.version spring-cloud.version2023.0.1/spring-cloud.version spring-cloud-alibaba.version2023.0.1.0/spring-cloud-alibaba.version /properties dependencyManagement dependencies !-- BOM 导入子模块不写版本号 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version${spring-boot.version}/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version${spring-cloud.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement逻辑说明父工程只做依赖版本管理不写业务代码。子模块继承父工程后引入spring-cloud-starter-gateway、spring-cloud-starter-openfeign这类依赖时不用写版本避免各模块版本打架。参数上spring-boot.version和spring-cloud.version的对应关系要去官方兼容表核对2023.0.x 对应 Boot 3.2.x这是硬约束。2.3 注册中心与配置中心选型注册中心用 Nacos它同时能当配置中心省一套组件。启动 Nacos 单机模式# 单机模式启动standalone 参数必须带否则默认集群模式会因找不到集群配置启动失败 sh startup.sh -m standalone # 验证访问控制台 8848 端口默认账号密码 nacos/nacos curl http://127.0.0.1:8848/nacos/v1/console/health/readiness每个业务服务的bootstrap.yml里声明注册和配置spring: application: name: recipe-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: dev # 用命名空间隔离环境别用 group 硬隔离 config: server-addr: 127.0.0.1:8848 file-extension: yaml shared-configs: ->CREATE TABLE recipe ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, title VARCHAR(128) NOT NULL, cover_url VARCHAR(512), status TINYINT DEFAULT 0 COMMENT 0草稿 1待审 2已发布 3下架, view_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE recipe_step ( id BIGINT PRIMARY KEY AUTO_INCREMENT, recipe_id BIGINT NOT NULL, sort_order INT NOT NULL, content TEXT, image_url VARCHAR(512), INDEX idx_recipe_sort (recipe_id, sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明status用状态机控制发布流程草稿不对外可见。idx_user_status支撑「我的菜谱」按状态筛选idx_recipe_sort支撑详情页按顺序拉步骤。参数上view_count这种高频自增字段不要每次浏览都UPDATE会锁行后面讲怎么异步累加。3.2 详情页缓存先缓存后查库别反过来菜谱详情是典型读热点一个爆款菜谱可能被反复打开。用 Redis 缓存详情key 设计成recipe:detail:{id}。public RecipeDetailVO getDetail(Long recipeId) { String key recipe:detail: recipeId; // 1. 先查缓存 String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, RecipeDetailVO.class); } // 2. 缓存未命中查库 RecipeDetailVO vo loadFromDb(recipeId); if (vo null) { // 3. 空值也缓存防止缓存穿透过期时间设短一点 redisTemplate.opsForValue().set(key, , 60, TimeUnit.SECONDS); return null; } // 4. 回写缓存加随机过期时间防雪崩 long ttl 1800 ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), ttl, TimeUnit.SECONDS); return vo; }逻辑说明这是标准的 Cache-Aside 模式。第 3 步缓存空值是防穿透的关键否则恶意请求不存在的 id 会全部打到数据库。第 4 步的随机 TTL 是防雪崩避免大批 key 同一时刻集体失效。参数上基础 TTL 1800 秒适合菜谱这种更新不频繁的数据随机抖动 300 秒足够打散。3.3 浏览计数用 Redis 攒批别直接打库view_count每次浏览都更新数据库是灾难。我一般用 Redis 的 Hash 攒着定时任务批量刷回。// 浏览时只操作 Redis不碰数据库 public void incrView(Long recipeId) { redisTemplate.opsForHash().increment(recipe:view:buffer, recipeId.toString(), 1); } // 定时任务每 30 秒刷一次 Scheduled(fixedDelay 30000) public void flushViewCount() { MapObject, Object buffer redisTemplate.opsForHash().entries(recipe:view:buffer); if (buffer.isEmpty()) return; buffer.forEach((id, count) - { recipeMapper.addViewCount(Long.valueOf(id.toString()), Integer.parseInt(count.toString())); }); // 刷完删除注意这里不是原子的极端情况下会丢少量计数可接受 redisTemplate.delete(recipe:view:buffer); }逻辑说明把高频写转成低频批量写数据库压力从每秒上千次降到每 30 秒一次。参数上fixedDelay设 30 秒是吞吐和实时性的折中要求实时性高可以缩到 5 秒。注意delete和entries之间不是原子的如果这期间有新浏览会丢对浏览量这种非核心指标可以接受要精确就得上 Lua 脚本或改用RENAME原子换 key。4. 跨服务调用OpenFeign 与网关的落地细节4.1 Feign 客户端定义与超时配置interaction-service 要调 recipe-service 校验菜谱是否存在用 OpenFeign 声明式调用。FeignClient(name recipe-service, fallbackFactory RecipeClientFallback.class) public interface RecipeClient { GetMapping(/api/recipe/{id}/exists) Boolean exists(PathVariable(id) Long id); } // 降级工厂能拿到异常原因 Component public class RecipeClientFallback implements FallbackFactoryRecipeClient { Override public RecipeClient create(Throwable cause) { return id - { log.warn(recipe-service 调用失败, id{}, cause{}, id, cause.getMessage()); return false; // 降级返回 false宁可拒绝互动也不阻塞 }; } }逻辑说明fallbackFactory比fallback好因为能拿到具体异常排查方便。降级返回false是业务决策——菜谱服务挂了点赞收藏这种依赖菜谱存在的操作直接拒绝比放行脏数据强。参数上Feign 默认超时很短必须显式配置feign: client: config: default: connect-timeout: 2000 # 连接超时 2 秒 read-timeout: 5000 # 读超时 5 秒 circuitbreaker: enabled: true # 开启熔断配合 fallback 生效4.2 网关统一鉴权把 JWT 校验挡在门外所有请求先过 gateway鉴权在网关做业务服务不用重复写。Component public class AuthFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token exchange.getRequest().getHeaders().getFirst(Authorization); String path exchange.getRequest().getURI().getPath(); // 白名单直接放行 if (path.startsWith(/api/user/login) || path.startsWith(/api/recipe/public)) { return chain.filter(exchange); } if (token null || !jwtUtil.verify(token)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 解析出 userId 塞进请求头下游服务直接取 Long userId jwtUtil.getUserId(token); ServerHttpRequest mutated exchange.getRequest().mutate() .header(X-User-Id, userId.toString()).build(); return chain.filter(exchange.mutate().request(mutated).build()); } Override public int getOrder() { return -100; } // 优先级高尽早执行 }逻辑说明网关做鉴权的好处是业务服务无感知X-User-Id透传下去下游直接信任。参数上getOrder返回 -100 保证鉴权在路由转发前执行。注意网关是 WebFlux 响应式栈别在这里写阻塞代码比如直接调数据库否则会拖垮整个网关。4.3 服务间数据一致性别用分布式事务用最终一致点赞要同时更新 interaction 的点赞表和 recipe 的点赞数跨服务了。新手第一反应是上 Seata但仿下厨房这种场景没必要。常见做法是本地消息表 定时补偿点赞先写本地表并记录一条待发送消息定时任务扫描消息去更新 recipe 的计数失败重试。Transactional public void like(Long userId, Long recipeId) { // 1. 本地事务写点赞记录 写消息表 likeMapper.insert(new Like(userId, recipeId)); messageMapper.insert(new OutboxMessage(recipe.like.incr, recipeId)); } // 2. 定时任务扫描消息表调 recipe-service 更新计数 Scheduled(fixedDelay 5000) public void dispatch() { ListOutboxMessage msgs messageMapper.selectUnsent(100); for (OutboxMessage m : msgs) { try { recipeClient.incrLikeCount(m.getBizId()); messageMapper.markSent(m.getId()); } catch (Exception e) { messageMapper.incrRetry(m.getId()); // 失败重试超过次数告警 } } }逻辑说明本地消息表把「写业务」和「发消息」放进同一个本地事务保证不丢。参数上扫描间隔 5 秒、每批 100 条是吞吐和延迟的平衡。这套方案牺牲强一致换可用性点赞数晚几秒更新用户完全无感比引入 Seata 简单太多。5. 避坑与排查仿下厨房后端最容易翻车的 5 个点5.1 服务注册上了但 Feign 调不通现象Nacos 控制台能看到 recipe-service但 interaction-service 调它报No instances available。原因通常是服务注册到了不同命名空间或不同 group或者消费者没开EnableFeignClients。解决先确认两边namespace和group完全一致再检查启动类注解最后看spring.cloud.nacos.discovery配置有没有被bootstrap.yml覆盖。5.2 配置中心改了配置不生效现象在 Nacos 改了 Redis 地址服务还是连旧的。原因是配置没加RefreshScope或者shared-configs的refresh没开。解决需要动态刷新的 Bean 加RefreshScope公共配置项确认refresh: true改完在 Nacos 点发布后看服务日志有没有Refresh keys changed。5.3 网关转发 404 但服务本身能访问现象直连 recipe-service 的端口能拿到数据走网关就 404。原因是网关路由的Path断言和服务的context-path对不上或者StripPrefix配置错了。解决检查路由配置里predicates的路径前缀如果服务配了server.servlet.context-path网关要么不剥离前缀要么剥离层数要对。5.4 缓存和数据库不一致现象改了菜谱标题详情页还是旧的。原因是更新时只改了库没删缓存或者先删缓存后改库中间被并发读回填了旧值。解决更新时用「先改库再删缓存」删缓存失败就丢进消息队列重试。别用「改库同时改缓存」并发下必乱。5.5 Feign 调用偶发超时现象平时正常高峰期偶尔报Read timed out。原因是默认超时太短或者被调服务线程池打满。解决把read-timeout调到 5 秒以上同时给 Feign 配独立的线程池隔离别和主业务线程池共用。再不行就上熔断让慢调用快速失败而不是拖垮调用方。6. 进阶用 Sentinel 给热点菜谱做限流以及怎么验证整套链路6.1 热点参数限流只限爆款菜谱的详情接口仿下厨房有个典型场景某个菜谱上了首页推荐瞬间流量全砸到它的详情接口。全局限流会误伤正常菜谱得用热点参数限流只针对特定 recipeId。SentinelResource(value recipeDetail, blockHandler detailBlockHandler) public RecipeDetailVO getDetail(Long recipeId) { // 业务逻辑 } // 被限流时的兜底返回降级内容而不是报错 public RecipeDetailVO detailBlockHandler(Long recipeId, BlockException ex) { return RecipeDetailVO.builder() .id(recipeId) .title(当前访问人数过多请稍后再试) .degraded(true) .build(); }逻辑说明SentinelResource标记资源blockHandler指定限流后的兜底方法方法签名要和原方法一致并多一个BlockException参数。参数上热点规则在 Sentinel 控制台配对recipeDetail资源的第 0 个参数即 recipeId设阈值比如单参数 QPS 超过 500 就限流其他菜谱不受影响。6.2 验证整套链路从注册到限流的自检清单搭完之后别急着写业务先按这个顺序验证一遍能省掉后面大量排查时间验证项操作预期结果服务注册看 Nacos 服务列表6 个服务全部在线配置拉取改 Nacos 配置并发布服务日志出现刷新记录网关路由通过网关端口访问接口正常返回非 404鉴权不带 token 访问受保护接口返回 401Feign 调用触发跨服务调用正常返回日志无超时熔断降级手动停掉 recipe-service返回降级结果不报 500限流压测热点接口超阈值后返回兜底内容6.3 我踩过的最大一个坑最后说个我自己的教训。早期我把所有服务的日志级别都开成 DEBUG觉得排查方便结果上线后磁盘两天就写满了服务集体挂掉。后来改成生产环境只开 INFO关键链路用 traceId 串起来配合 SkyWalking 看调用链比翻日志高效得多。微服务这东西可观测性比功能本身还重要——功能写错了能改线上出问题定位不到才是真的抓瞎。这套仿下厨房的 SpringCloud 后端拆服务的边界、缓存的粒度、跨服务一致性的取舍每一处都是在「简单」和「正确」之间做选择。别一上来就追求大而全先把注册、网关、一个核心服务跑通再逐个加踩坑的代价会小很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表