1. 为什么“实习生管理”也敢上微服务?——课题价值与架构动机
1.1 中小企业实习管理的真实痛点
第一次看到“springcloud基于微服务的中小企业实习生管理系统”这个毕业设计题目时,我的第一反应和大部分同学一样:实习生管理不就是录入档案、记个考勤、打打分吗?这种系统也需要上微服务?等我真去梳理业务才发现,这个题目比想象中的复杂得多,而微服务在这里并不是凑技术热度,而是能讲清楚架构演进思路的好素材。
中小企业实习管理到底难在哪?相对于成熟企业自有的HR系统,中小企业通常没有专职系统工程师,实习生又分散在不同部门、不同项目组,招聘、入职、排班、周报、考核、离职这条链路往往同时涉及HR、部门导师、项目负责人和实习生本人。我见过不少团队用Excel排实习周期,用微信群收日报,月底再由HR翻聊天记录补考勤。这种做法的最大问题是信息分散、过程不受控,而且数据量一旦到了几十个实习生,审核、统计、出勤、评分这些环节就会互相扯皮。
如果把这套流程抽象成系统需求,至少包含管理员维护岗位和用人部门、HR对简历进行筛选和实习录用、导师给实习生分配任务并批改日报、实习生提交日报周报和请假、HR定期考核并汇总成绩、实习结束进行归档和证书生成。这些功能表面上都是CRUD,但角色差异大、状态流转多、数据归属不同。单体也能做,但到后期经常会出现“权限判断塞在Service里”“简历归档逻辑被考核模块引用”这类耦合。微服务从架构上逼着我们把每个业务域拆开,权限、评审、任务各自为政,这恰好是这个毕设课题最值得展开的点。
1.2 毕业设计选微服务的合理性与边界
那是不是说所有管理类毕设都应该上微服务?完全不是。这个课题适合微服务,是因为它天然存在多个独立业务域,而且每个域都有独立的生命周期和扩展点。比如招聘服务在招聘季可能访问量高,可以单独扩容;考核服务要等月底才有压力,平时只需要很小的资源。这种差异化的伸缩需求,在单体应用里体现不出来。
另外,一个人完成毕设并不意味着不能采用微服务。我的经验是:先把服务边界理清,再按服务逐个实现,开发顺序反而更清晰。认证、招聘、档案、任务、考核、消息六个服务,从用户故事出发,基本能覆盖实习生的完整生命周期。拆太细不行,比如把“导师分配”单独拆成一个服务,服务之间会为了一个简单字段来回调用,那是自找麻烦;拆太粗也不行,比如把考核和任务放在一起,月底评分时又容易把任务表拖慢。
我倾向于用三个标准判断拆分粒度。第一是看数据是否独立,一个核心业务实体不能被两个服务同时拥有;第二是看流程节点是否清晰,比如“入职前”“实习中”“实习后”天然分界;第三是看未来是否有单点扩展需求,比如消息服务将来要对接多种通知渠道。满足这三条,拆出来的服务才有说服力,答辩时评委问你“为什么这么拆”也能答得有理有据。
2. 业务域拆分:六个服务如何覆盖实习生全生命周期
2.1 服务清单与职责边界
我最终把系统分成六个微服务,每个服务单独启动、单独数据库。虽然整体代码量比单体多,但每个服务内部都很清爽,职责边界也容易描述:
| 服务名 | 核心职责 | 主要业务对象 |
|---|---|---|
| user-service | 账号、角色、菜单权限、登录鉴权 | 用户、角色、权限 |
| recruit-service | 岗位发布、简历投递、面试安排、录用 | 岗位、简历、面试计划 |
| archive-service | 实习生档案、部门岗位分配、导师关系 | 实习生档案、分配记录 |
| task-service | 任务派发、日报周报、请假考勤 | 任务、日报、周报、请假单 |
| assess-service | 考核配置、导师评分、成绩汇总 | 考核计划、评分记录 |
| notify-service | 站内信、邮件通知、事件幂等处理 | 通知模板、通知记录 |
这里的边界设计逻辑是“数据主人在哪里,业务逻辑就放在哪里”。比如实习生信息只有archive-service能改,task-service需要实习生姓名时走Feign去查,而不是在task库冗余一份姓名。冗余会导致数据源不一致,这也是微服务初学者最容易犯的错误。
2.2 数据库拆分与公共字段设计
每个服务一个数据库,物理隔离。数据库命名与服务名对应:user_db、recruit_db、archive_db、task_db、assess_db、notify_db。表结构上,所有表都保留了公共审计字段,方便后面排查数据问题。
CREATE TABLE tb_student_archive ( id BIGINT PRIMARY KEY COMMENT '雪花ID', user_id BIGINT NOT NULL COMMENT '关联user-service用户ID', student_name VARCHAR(50) NOT NULL, school_name VARCHAR(100), major_name VARCHAR(100), dept_id BIGINT NOT NULL COMMENT '部门ID', mentor_user_id BIGINT NOT NULL COMMENT '导师用户ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-实习中 2-已离职 3-已转正', create_by BIGINT DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实习生档案表';主键我统一用雪花ID,而不是数据库自增ID。原因是服务间调用时经常需要把ID从A服务传到B服务,自增ID本身没问题,但如果未来做数据合并或分库分表,自增主键很容易撞车。雪花ID生成的ID是数字类型,能直接存BIGINT,排序性能和自增基本没有差别。
公共字段可以直接在项目里用MyBatis-Plus的元数据处理器自动填充,不用每张表都写一遍:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "deleted", Integer.class, 0); this.strictInsertFill(metaObject, "version", Integer.class, 1); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }2.3 服务间接口关系与通信方向
服务间通信方向很关键,画一张简单的架构关系图:
前端 -> Spring Cloud Gateway |-> user-service |-> recruit-service |-> archive-service |-> task-service |-> assess-service |-> notify-service archive-service -> user-service:查询用户姓名/角色 task-service -> user-service:查询导师和实习生基本信息 task-service -> archive-service:校验实习生是否在岗 assess-service -> task-service:拉取日报完成情况 notify-service <- task-service / assess-service:发送通知这个图里有一条必须守住的原则:不允许出现服务间的环形调用。比如task-service调assess-service取考核结果,assess-service又调task-service取任务完成度,形成循环。一旦环了,排查问题和后续扩展都会非常痛苦。我的做法是把公共的“任务完成度统计”下沉到task-service的统计接口里,assess-service只拉取数据,不要再往回调。
3. Spring Cloud关键组件落地:从注册中心到限流熔断的配置细节
3.1 注册中心与配置中心:直接上Nacos的理由
服务拆完之后,第一件事是解决“服务之间怎么找到对方”。Spring Cloud领域可选的注册中心很多,但我最终选了Nacos,理由很实际:它同时承担了注册中心和配置中心,毕业设计阶段不用维护两套基础设施。Eureka作为注册中心已经进入维护期,我个人的建议是新项目不用再花时间接触。
Nacos启动方式很简单,本地开发直接单机模式运行:
sh startup.sh -m standalone各个微服务统一通过配置中心拉取公共配置,而不是每个服务里拷一份。下面是user-service的接入配置:
spring: application: name: user-service cloud: nacos: discovery: server-addr: localhost:8848 config: server-addr: localhost:8848 file-extension: yml namespace: student-manage要注意的是,配置中心的namespace不能乱用。如果所有环境都用默认public,很容易出现“本地连了生产的配置”这类事故。我习惯按环境分namespace:dev、test、prod各一个。毕设演示用dev就够了。
3.2 网关鉴权:Token校验到底放在哪一层
微服务架构里最容易被问到的就是“登录状态怎么在多服务里共享”。我的方案是用Spring Cloud Gateway做统一鉴权,各业务服务不再自己解析Token。前端登录成功后拿到JWT,之后每次请求都放到Authorization头里,网关过滤器统一校验。
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final List<String> WHITE_LIST = Arrays.asList("/auth/login", "/auth/captcha"); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); if (WHITE_LIST.contains(path)) { return chain.filter(exchange); } String token = exchange.getRequest().getHeaders().getFirst("Authorization"); // 解析JWT、校验签名,这里省略具体校验逻辑 Long userId = parseUserId(token); if (userId == null) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } ServerHttpRequest request = exchange.getRequest().mutate() .header("X-User-Id", String.valueOf(userId)) .build(); return chain.filter(exchange.mutate().request(request).build()); } @Override public int getOrder() { return -100; } }这里有个细节:自定义头X-User-Id必须是网关生成的,不能被前端伪造。我在网关里先把请求里的X-User-Id删掉,再重新写入从JWT解析出的用户ID,这样下游服务才能信任这个字段。同时业务服务里不要直接信header里的角色,最好通过user-service校验权限,或者把角色也放进JWT并由网关解析后透传。
3.3 服务间调用:OpenFeign的接口复用与超时设置
服务间同步调用的首选是OpenFeign。它好用的地方是能像写本地接口一样声明远程服务:
@FeignClient(name = "archive-service", contextId = "archiveFeignClient") public interface ArchiveFeignClient { @GetMapping("/api/archive/interns/{userId}") InternDTO getInternByUserId(@PathVariable("userId") Long userId); }OpenFeign里有几个细节不能忽略。第一个是contextId:如果同一个服务里定义了多个FeignClient指向同一个name,Spring容器会报Bean冲突,给每个Client定义一个不同的contextId就能避免。第二个是超时时间,默认连接超时和读取超时都很短,跨服务查询如果涉及慢SQL,很可能两三秒就被熔断。我在配置里一般这样调:
feign: client: config: default: connectTimeout: 3000 readTimeout: 10000同时把Feign接口所在包的日志级别开到DEBUG:
logging: level: com.example.feignclient: DEBUG调试时能看到每次调用的URL、请求头和响应耗时,排查问题比猜快得多。
3.4 流量防护:Sentinel规则在实习管理场景下的最小配置
实习生管理系统本身并发不高,但我仍然给核心接口配了Sentinel,不为别的,就为在答辩时能说清楚“系统在异常流量下怎么自我保护”。每天下班前实习生集中提交日报,如果瞬间有大量请求,数据库很容易被打满。
我在task-service里对POST /api/task/report这个接口单独做了QPS限制,阈值放到Nacos动态配置里:
spring: cloud: sentinel: transport: dashboard: localhost:8858启动Sentinel控制台:
java -Dserver.port=8858 -Dcsp.sentinel.dashboard.server=localhost:8858 \ -Dproject.name=sentinel-dashboard \ -jar sentinel-dashboard.jar阈值设置不要拍脑袋,按业务量倒推:假设50个实习生,每人5分钟提交一次日报,峰值QPS最多不到1;就算有外部脚本刷接口,QPS能到几十。我直接限制每台实例的QPS为20,超出的请求返回提示,而不是让它们全部打入数据库。对于毕设来说,这样设计已经完全够用。
4. 跨服务问题排查实录:登录态丢失、分布式事务、文件上传
4.1 登录态丢失:网关透传Header的坑
做这个系统时我在“登录态丢失”上耗了一个下午。现象是:通过网关访问任务服务,任务服务里能收到Authorization,但拿不到网关塞进去的X-User-Id。一开始我怀疑是下游服务的拦截器把header处理掉了,反复检查Controller无果,最后在网关过滤器里打了断点才发现问题——我在设置header时没有把新的ServerHttpRequest应用到exchange上。
// 错误写法 exchange.getRequest().mutate().header("X-User-Id", userId); // 只修改了旧对象,exchange里的request还是原来的 // 正确写法 ServerHttpRequest newRequest = exchange.getRequest().mutate() .header("X-User-Id", userId) .build(); ServerWebExchange newExchange = exchange.mutate().request(newRequest).build(); return chain.filter(newExchange);除了网关透传,OpenFeign在服务间调用时默认不会携带网关请求头。因为业务服务之间调用时已经不需要传递原始JWT,但X-User-Id需要继续往下传。这种情况要加一个Feign的RequestInterceptor:
@Configuration public class FeignHeaderConfig { @Bean public RequestInterceptor requestInterceptor() { return template -> { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs != null) { String userId = attrs.getRequest().getHeader("X-User-Id"); if (userId != null) { template.header("X-User-Id", userId); } } }; } }RequestContextHolder在异步线程里取不到上下文,需要额外处理。我建议在业务代码里不要依赖隐式传递,而是把用户ID作为DTO字段显式传参,这样逻辑更明确,测试也更简单。
4.2 “发布实习任务后立即通知”的一致性:不用Seata的处理
任务服务发布任务后,要通知相关导师和管理员。最直观的写法是在任务Service里同步调用notify-service的接口。问题在于,如果任务记录已经插入本地库,而通知接口因网络或参数问题失败,任务就“发布成功但没人知道”,导师和实习生都会漏看。
微服务里解决这类问题可以引入Seata这样的分布式事务框架,但我觉得对毕设来说太重了。我采用的是本地消息表加定时重试:任务表和消息表放在同一个数据库里,用同一个本地事务写入。
CREATE TABLE tb_task_notify_record ( id BIGINT PRIMARY KEY, task_id BIGINT NOT NULL, notify_type VARCHAR(20) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待发送 1-已发送', retry_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务通知本地消息表';流程是这样的:先在task-service开启本地事务,插入任务记录和tb_task_notify_record;事务提交后,立即调用notify-service发送通知;如果调用成功,把消息记录状态改成已发送;如果调用失败,消息记录保持待发送,后台定时任务扫描状态为0且重试次数小于5的记录,重新发送。notify-service的发送接口还要按task_id + notify_type做幂等,重复调用不会产生重复通知。这套方案最终能保证“最终一致性”,还能把业务逻辑讲清楚,比写一堆理论上的分布式事务实在。
4.3 头像与附件上传:网关路由大小限制调整
这个坑几乎是毕设必现。最开始我在archive-service里调整了spring.servlet.multipart.max-file-size=10MB,但上传超过几MB的简历PDF仍然报错。后来发现请求还没到archive-service,在Spring Cloud Gateway这一层就被Netty限制了。排查思路就是先看服务日志有没有接收到上传请求,结果发现网关直接返回了4xx,根本没有转发记录。
解决方案是在网关配置codec内存上限:
spring: codec: max-in-memory-size: 10MB同时在后端服务里保留multipart配置:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB如果文件更大,就不建议走HTTP请求体直传了,改成先由客户端调用一个预签名上传地址,再把文件地址回传给业务接口。毕设里10MB已经足够覆盖简历和头像场景。
4.4 MyBatis-Plus分页插件在多个服务重复配置的坑
MyBatis-Plus的分页插件如果每个服务各自复制一份配置,很容易出现A服务配置了DbType.MYSQL,B服务忘记配置,同一个分页接口在B服务返回整表数据。更隐蔽的问题是,Feign接口返回的IPage对象在序列化后字段名变化,前端拿不到total。
我最后的做法是写一个公共的mybatis-plus-common模块,把分页拦截器、自动填充、公共字段处理统一放在里面:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }所有业务服务依赖这个公共模块,避免每个服务复制配置导致不一致。分页对象尽量不直接跨Feign传输,而是把它转换成自定义的PageResult<T>,里面只有list、total、pageNum、pageSize四个字段,序列化稳定,前端使用也统一。
5. 性能与安全性优化:这套系统在什么样的情况下会出问题
5.1 并发量级:几十人规模下的核心瓶颈其实在数据库连接与慢SQL
中小企业实习生几十人到几百人,系统的并发压力天花板很低。真正的问题经常出在慢SQL和服务间反复调用上。比如任务列表接口,先查当前用户的任务分页,再循环遍历每条任务通过Feign查实习生姓名和导师姓名,10条任务至少多出20次RPC。数据量小的时候没感觉,数据一多整个接口就变慢。
优化办法是批量查询:收集任务列表里的userId集合,一次性调用user-service批量接口返回用户映射:
Map<Long, UserDTO> userMap = userClient.listUsersByIds(userIds) .stream() .collect(Collectors.toMap(UserDTO::getId, Function.identity()));再通过这个Map填充任务列表里的姓名信息。这样一次RPC拿到所有姓名,接口耗时从几百毫秒降到几十毫秒。这种优化不炫技,但非常实用,也是答辩时能讲“性能分析”的好素材。
5.2 密码存储与接口防刷
密码存储必须用带盐的哈希算法,不要用MD5。我使用的是现成的BCryptPasswordEncoder,注册时加密,登录时匹配。为什么不用MD5?因为现在彩虹表太普遍,即使加固定盐也容易被碰撞,BCrypt每次生成不同盐,安全性高很多。
登录接口是容易被刷的对象。我的策略是最小成本限流:Redis里记录用户IP加手机号,60秒内失败5次就锁10分钟,同时在网关层用Sentinel限制登录接口QPS。这样即使有人拿到账号也拿不到验证码,逻辑简单但能挡住大部分脚本。
JWT过期时间我给实习生端设置为30分钟,使用过程中做会话续期;管理员端也是30分钟无操作自动失效。这个时间不宜过长,毕竟涉及个人档案和评分数据。如果担心每次都重新登录太麻烦,可以用refresh token机制,但毕设中简单会话续期接口就足够了。
5.3 MySQL连接池与Redis缓存的使用边界
每个微服务都配置独立的数据库连接池,如果只给默认参数就容易出问题:六个服务每个默认20个连接,加起来120个连接,数据库默认连接数只有151,稍不留神就把连接池打满。我在每个服务里都显式调小了maximum-pool-size:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5如果某些服务高峰期需要更多连接,再单独调大,而不是一刀切。
Redis在这个系统里的定位是:验证码、公告热点缓存、登录失败计数、岗位列表缓存。不要把业务主表都塞进Redis,实习生档案这类数据一致性要求高、访问量又不大的数据,老老实实查MySQL反而更可靠。缓存最怕的是“看起来快,但数据旧”。热点岗位列表可以做30秒缓存,日报统计结果可以做分钟级缓存,评分数据不能缓存。这个边界想清楚,整个系统的复杂度就降下来了。
6. 从开发到答辩:部署演示与评委追问准备
6.1 Docker Compose一键启动的编排思路
毕业设计交到评委手里,现场最怕的是环境起不来。我把整套系统用Docker Compose编排,本地环境一条命令启动,省去手动起MySQL、Redis、Nacos和各服务的麻烦:
version: "3.8" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 ports: - "3306:3306" redis: image: redis:6.2 ports: - "6379:6379" nacos: image: nacos/nacos-server:v2.2.0 environment: MODE: standalone ports: - "8848:8848" gateway: build: ./gateway ports: - "8080:8080" depends_on: - nacos - user-service user-service: build: ./user-service depends_on: - nacos - mysql注意depends_on只能控制容器启动顺序,不能保证Nacos里的服务已经注册完成。我给主要服务加了健康检查,演示前等日志出现“Registering service with nacos”再放开操作,比盲目等待稳定。
6.2 演示数据准备与演示脚本设计
演示环节最容易翻车的是“现场注册账号、现场录数据”。空库演示既慢又没有说服力。我会提前准备一套完整的演示数据,账号覆盖四种角色:
| 角色 | 账号 | 演示操作 |
|---|---|---|
| 管理员 | admin | 创建岗位、查看部门、停用账号 |
| HR | hr01 | 录入实习生档案、分配部门 |
| 导师 | mentor01 | 派发任务、批日报、打分 |
| 实习生 | intern01 | 提交日报、填写周报、发起请假 |
演示顺序我建议按业务故事走:管理员发布实习岗位,HR收到简历并录用,导师给实习生分配任务,实习生提交今日日报,导师在考核页面打分,管理员在统计页面看到综合成绩。一个完整故事讲下来不到五分钟,但把六个服务基本都串了一遍。
演示前把各个服务的日志窗口打开,一边操作一边能看到网关日志转发到哪个服务,让评委直观感知“这不是一个单体应用在不同模块间跳转”。这一步很加分,比口头解释“我们用了微服务”要有说服力得多。
6.3 高频答辩问题与回答思路
评委最喜欢问的问题其实就那么几个,提前准备就能稳住。
第一个问题:为什么不直接用单体,非要微服务?我平时是这样回答的:实习管理系统六个业务域有独立的数据归属和扩缩容需求,比如招聘季简历接口和月底考核接口的负载峰值时间完全不同。微服务通过独立部署把这种差异性显式表达出来,也为后续扩展留出位置。如果评委追问,我会主动承认单体在几十人规模下完全够用,微服务是面向未来架构演进的选择。
第二个问题:服务拆分的粒度是怎么定的?我给的答案不是拍脑袋,而是基于“数据主权和依赖方向”。每个业务实体只有一个服务拥有写权限,其他服务只能通过API读取;同时控制调用方向不形成环,模块职责不能同时跨两个生命周期阶段。
第三个问题:跨服务事务怎么保证?如果只答“用了Seata”,可能被追问补偿细节。我倾向于介绍本地消息表加定时重试的最终一致性方案,强调在中小规模场景下,不是所有操作都需要强一致,通知失败可以重试,数据不会丢就已经满足需求。
第四个问题:网关存在的意义是什么?我从三个角度答:统一入口与路由、统一鉴权、统一限流与日志。同时指出网关层不做业务逻辑,避免变成“上帝服务”。
第五个问题:有没有压测数据?我不编数据,因为毕设环境不具备严谨的压测条件。我会诚实说明做了简单的JMeter冒烟测试,重点展示通过慢SQL排查、批量接口优化、Sentinel限流配置来提升系统容错的过程。诚实而且有技术含量。
答辩的心态上,我想强调一点:不要对微服务框架的每个细节都如数家珍,更重要的是把“业务为什么这么拆”“异常情况下系统怎么自保”“数据一致性如何保证”这三件事讲透。做过一遍本地消息表,胜过背十个分布式事务术语。
整套系统做下来,我最真实的感受是:微服务毕业设计的难度不在于写多少代码,而在于把服务边界、配置链路和跨服务异常处理串起来。刚开始配置Nacos和网关时我也踩了很多坑,但当你看到一条请求从网关穿过三个微服务最终回到前端,日志里每一跳都清清楚楚时,那种“整套链路被我打通了”的感觉,比多写两个CRUD接口实在得多。如果让我重做一次,我不会再多拆一个服务,而是把已有服务的接口契约、幂等设计和演示数据打磨得更细。这一点,比多写一个微服务有用得多。