ARTICLE DETAIL

资讯详情

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

人力资源管理系统微服务架构设计:从单体到分布式实战解析

人力资源管理系统微服务架构设计:从单体到分布式实战解析 1. 项目定位与整体方案选型做这套人力资源管理系统最初并不是为了追赶什么技术风口。当时团队里同时跑着员工档案、考勤、薪资几摊业务最开始是一个单体应用Spring Boot 2.x打成war包扔在Tomcat里跑。一开始还好等用户量上来之后问题全暴露了每周发版要掐着点改一个考勤模块的字段就得把整个系统重新打包数据库表混在一起薪资模块和考勤模块互相抢连接池资源另外一个项目组想独立迭代招聘模块根本没法拆开。后来才下定决心重构把系统升级成SpringBootVueSpringCloud微服务分布式架构。这套系统的核心业务覆盖了员工管理、组织架构、考勤打卡、薪资核算、招聘管理和权限中心六大模块前端用Vue做了独立的后台管理端后端按业务域拆分成多个微服务注册中心用Nacos网关用Spring Cloud Gateway文件存储落到MinIO。整体架构从单体演进成了分布式真正做到了模块独立部署、独立扩展、互不干扰。这篇文章我把踩过的坑、设计方案、核心实现细节都整理出来适合正在做毕业设计、准备跳槽写项目经验、或者公司打算重构内部管理系统的同学参考。全篇不吹不黑只讲实际怎么做、为什么这么做、哪些方案千万别碰。1.1 为什么选微服务而不是继续堆单体很多HR系统在市面上就是标准的单体软件几十万块钱一套功能齐全跑得也不错。那为什么还要上微服务这里有个前提不是所有场景都需要微服务。如果你只是给几十人用每天几百次访问单体反而更省事。我当时的判断依据是三点业务模块之间的负载特征差异太大。考勤模块是上下班高峰期写入密集薪资模块是月底那几天CPU飙升招聘模块日常访问量很低。混在一起部署任何一个模块的高峰都会拖累其他模块。团队需要并行交付。几个后端开发同时改代码单体仓库合并冲突不断测试发布流程被严重卡住。未来的伸缩方向不确定。公司可能后续要做移动端、要对接钉钉/企微、要给外部供应商开接口拆分出来之后边界清楚扩展成本低。这三点综合下来微服务是合理的选择而不是为了简历上有“微服务”三个字硬拆。如果你判断下来没有这些压力老老实实用单体等到规模大了再演进完全没问题。1.2 技术栈选型版本锁定和组件取舍版本问题非常关键。SpringCloud和SpringBoot之间有严格的版本对应关系配错了编译都过不去。我用的是Spring Boot 3.2.x Spring Cloud Alibaba 2023.x对应的Nacos Server 2.3.x。Spring Boot 3.xJDK最低要求17。如果你公司还停留在JDK8老老实实用Spring Boot 2.7.x Spring Cloud 2021.x别硬上3.x。注册中心用Nacos而不是EurekaEureka 2.x已经停止维护了Nacos功能更强服务注册发现、配置中心二合一省掉一个Spring Cloud Config。网关用Spring Cloud Gateway而不是ZuulZuul 1.x基于Servlet性能不如Gateway的WebFlux而且Spring Cloud官方已经转向Gateway。前端用Vue3 Element PlusVue2已经进入维护末期Element Plus对Vue3支持完善表单组件、表格组件基本开箱即用做后台管理系统效率非常高。文件存储用MinIO头像、合同扫描件、入职材料等附件MinIO部署简单接口标准S3协议兼容比FastDFS好维护得多。这套组合在2025年来看依然是主流稳定档位社区问答多、踩坑资料丰富出了问题很容易搜到解决方案。2. 微服务拆分与数据模型设计拆服务是整套系统的地基这里一旦拆错后面全是返工。我见过很多项目把微服务拆成了“微单体”服务名很多实际上每个服务什么都是自己一套重复代码满天飞。正确的思路是按业务域拆分不是按技术层拆分。2.1 服务拆分的边界怎么划我最终把后端拆成六个服务服务名职责范围独立数据库auth-service登录认证、令牌发放、用户管理hr_authemployee-service员工档案、家庭成员、教育经历hr_employeeorganization-service部门、岗位、职级、编制hr_orgattendance-service打卡记录、请假审批、考勤统计hr_attendancesalary-service薪资项目、核算规则、工资条hr_salaryrecruitment-service招聘需求、简历、面试流程hr_recruitment每个服务的边界都遵循一个原则一个服务内部的表之间关系紧密跨服务之间只有通过接口调用或者事件通知绝不互相查对方的数据库表。举个例子员工表和部门表在业务上关联很强如果把员工和部门放在同一个库里开发很方便但组织架构调整的频率远低于员工入离职负载特征完全不同所以拆成两个服务。员工表里只存department_id查询部门名称通过Feign接口调用organization-service获取。2.2 数据库拆分策略与核心表设计数据库采用每个服务独立库的方案虽然联表查询不方便但换来的是服务之间的完全解耦。MySQL连接池不会被对方拖垮某个服务的慢查询不会拖慢其他服务的接口。核心表设计我挑员工表和部门表说一下这是整套系统最基础的两张表。员工表employee-service库CREATE TABLE employee ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, emp_no varchar(20) NOT NULL COMMENT 工号, name varchar(50) NOT NULL COMMENT 姓名, gender tinyint NOT NULL COMMENT 性别 1男 2女, id_card varchar(18) DEFAULT NULL COMMENT 身份证号, phone varchar(20) NOT NULL COMMENT 手机号, email varchar(100) DEFAULT NULL COMMENT 邮箱, department_id bigint DEFAULT NULL COMMENT 部门ID, position_id bigint DEFAULT NULL COMMENT 岗位ID, hire_date date NOT NULL COMMENT 入职日期, status tinyint NOT NULL DEFAULT 1 COMMENT 状态 1在职 2离职, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_emp_no (emp_no), KEY idx_department (department_id), KEY idx_status (status) ) ENGINEInnoDB COMMENT 员工基本信息表;部门表organization-service库CREATE TABLE department ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, parent_id bigint NOT NULL DEFAULT 0 COMMENT 上级部门ID0表示根, name varchar(100) NOT NULL COMMENT 部门名称, leader_id bigint DEFAULT NULL COMMENT 负责人员工ID, sort int NOT NULL DEFAULT 0 COMMENT 排序, status tinyint NOT NULL DEFAULT 1 COMMENT 状态 1启用 2停用, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_parent (parent_id) ) ENGINEInnoDB COMMENT 部门表;两张表都加了create_time和update_time插入和更新时由MyBatis Plus自动填充查问题的时候非常方便。员工表还建了idx_department因为HR后台最频繁的操作就是按部门筛选员工列表这个索引必须有。2.3 服务间通信与数据一致性方案服务拆开之后通信就是绕不开的问题。我的方案是同步调用为主、异步通知为辅。同步场景用Feign。比如考勤服务需要员工信息判断打卡资格通过Feign调用employee-service的接口获取。Feign配置里要重点注意超时时间默认连接超时才1秒本地开发的时候服务启动慢很容易超时。我一般配置成连接超时3秒、读超时10秒并且开启重试但重试只对GET接口生效POST接口重试要小心重复提交问题。异步场景用RocketMQ。比如员工入职之后需要同步创建企业微信账号、开通邮箱、初始化考勤规则这些操作不需要在员工保存的请求链路里同步完成发个消息出来各个服务自己去消费。数据一致性这里重点说一下分布式事务。刚开始设计的时候我差点在薪资计算链路里直接上Seata分布式事务。后来仔细想了一下薪资计算的流程是读取考勤汇总数据加载员工信息计算应发工资生成工资条。整个链路跨越考勤和薪资两个服务但中间环节根本不需要强一致考勤数据是已经发生过的事实员工信息也不会在算薪的那几秒内频繁变更。最终一致足够没必要为了一个“看起来专业”的分布式事务给系统引入巨大的性能损耗。那什么时候才需要Seata如果是用户下单同时扣库存、转账这种资金强一致场景必须保证要么都成功要么都失败才需要引入分布式事务框架。我这边只有财务确认工资发放回写薪资状态这一个场景用了Seata的AT模式因为钱的事情不能含糊。3. 后端核心链路实现认证、权限与关键业务中后台管理系统的后端排在第一位的是安全第二位是权限业务功能反而是最简单的CRUD。这一节把认证鉴权、权限模型、分布式锁和文件存储这几个核心链路讲透。3.1 网关统一鉴权与JWT令牌校验认证流程我采用了标准的 JWT Spring Security OAuth2方案。流程是这样的用户在前端登录页输入账号密码。请求先打到网关网关对/auth/login这个路径放行。auth-service验证用户名密码验证通过后生成JWT令牌返回给前端。前端把令牌存到localStorage后续每个请求在Header里带Authorization: Bearer xxx。网关的全局过滤器拦截所有请求解析校验JWT。校验通过就把用户信息放到请求头转发到下游微服务。网关过滤器的核心代码逻辑不复杂但有一个坑必须注意网关只能用WebFlux的依赖不能引入spring-boot-starter-web不然会启动报错。这个坑几乎每个第一次做Spring Cloud Gateway的人都会踩。Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getPath().value(); // 白名单放行 if (path.startsWith(/auth/login) || path.startsWith(/auth/captcha)) { return chain.filter(exchange); } String token exchange.getRequest().getHeaders().getFirst(Authorization); String jwt token.replace(Bearer , ); try { Claims claims JwtUtil.parseJwt(jwt); ServerHttpRequest request exchange.getRequest().mutate() .header(X-User-Id, claims.get(userId).toString()) .header(X-Username, claims.get(username).toString()) .build(); return chain.filter(exchange.mutate().request(request).build()); } catch (Exception e) { return unauthorized(exchange); } } }令牌设计上有一点经验值得分享JWT里只放userId、username、角色编码绝对不要放员工姓名、手机号、身份证这些敏感信息。JWT本身只是base64编码不是加密的放进去等于裸奔。3.2 RBAC权限模型与前端动态路由权限模型用的是最经典的RBAC五张表用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。菜单表设计成树形结构一级菜单是“员工管理”“考勤管理”这种页面入口二级菜单是具体功能页再往下一层是按钮比如“新增员工”“导出员工列表”。这套模型的巧妙之处在于前端路由直接走后端下发的菜单数据动态生成。用户登录成功后auth-service查询当前用户的菜单和按钮权限组装成树形结构返回给前端。前端根据这份数据动态注册路由没权限的页面直接不注册从根上杜绝了用户自己改URL进入无权限页面的问题。按钮级权限的处理是这样的菜单数据里每个按钮带一个权限标识比如employee:add前端在按钮上通过自定义指令判断当前用户有没有这个权限标识没有就在页面上把按钮隐藏或置灰。这样既实现了功能权限也让界面体验更友好而不是直接报403。有一个细节我特别想提醒后端接口也必须做权限校验不能只靠前端隐藏按钮。前端隐藏只是体验层面的优化真正的安全防线在后面。每个服务里用Spring Security的PreAuthorize(hasAuthority(employee:add))做接口级校验两条线缺一不可。3.3 考勤打卡场景下的分布式锁实践考勤打卡是并发量最高的接口典型的场景是员工上班高峰期同时打卡恰好网络抖动前端的请求超时后自动重试同一秒钟同一个员工可能发来两三次打卡请求。如果不做处理数据库里就会出现重复打卡记录。前期在单机上用synchronized锁对象可以解决但微服务部署之后多个实例之间synchronized根本不生效A实例锁住的是JVM内部的对象B实例照样能进来。这时候就需要Redis分布式锁。我用的Redisson实现Redisson提供了现成的RLock底层实现是Redis的SETNX 过期时间 Lua脚本锁续期比自己手写Redis锁靠谱得多。这段代码实际效果非常稳定public void punchCard(PunchCardDTO dto) { String lockKey lock:punch: dto.getEmployeeId() : dto.getDate(); RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等3秒锁10秒自动释放 if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 检查是否已打卡 boolean existed checkPunchExists(dto.getEmployeeId(), dto.getDate()); if (existed) { throw new BizException(今日已打卡请勿重复操作); } // 记录打卡数据 savePunchRecord(dto); } } finally { // 注意判断当前线程是否持有锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这个方案实现简单效果明显面试被问分布式锁的时候也能讲清楚原理。踩坑提醒锁key一定要带上日期和员工ID如果只锁员工ID员工上午打一次、下午打一次两次没有冲突却被串行了。3.4 薪资计算链路的分布式事务与幂等设计薪资计算是整个系统里最复杂的业务链路。月度算薪时系统要同时处理几千名员工每个人的薪资由基本工资、绩效、考勤扣款、社保公积金、个税等多个项目组成。设计上我把这条链路拆成三步salary-service发起算薪批次生成一个唯一的批次号。针对每个员工salary-service从attendance-service获取考勤汇总数据从employee-service获取员工职级和薪资档位。所有单项算完后汇总实发金额生成工资条状态置为“待确认”。分布式事务在这里用Seata AT模式处理。AT模式的原理说实话不复杂事务开始前记录数据快照提交后自动删除快照回滚时用快照恢复数据。它对业务侵入非常小原本的SQL不用改加个GlobalTransactional注解就行。但还有一个更重要的东西是幂等。算薪接口如果被重复调用比如前端提交了两次或者Feign重试触发补偿事务重复执行就会算出两份工资。我在算薪的入口加了一个基于Redis的幂等处理每次请求带一个batchNoRedis里以idempotent:salary:{batchNo}为key第一次请求进来能成功写入第二次再写就判断这个key已存在直接返回第一次的结果用Redis做幂等需要设置合理的过期时间根据业务周期来否则长时间占内存也不能只在数据库查因为并发情况下两次请求可能同时查到“不存在”所以必须用Redis这种原子操作来保证“判断写入”的原子性。3.5 文件服务集成MinIO头像与附件管理的落地员工头像、学历证明、劳动合同扫描件这些文件不能直接存MySQL数据库也不能堆到应用服务器本地磁盘本地存储的致命问题是应用服务做了多实例部署之后请求落在A实例文件上传到A实例的磁盘下次刷新页面请求落在B实例B实例上根本找不到这个文件。解决方案是引入MinIO部署一个独立的文件服务节点。SpringBoot整合MinIO的核心配置minio: endpoint: http://192.168.1.100:9000 access-key: hr-admin secret-key: hr-admin-secret bucket: hr-files上传流程封装成一个统一的FileService调用方只需要传文件流和业务类型Service内部自动生成存储路径、上传、返回访问URL。存储路径我习惯按业务类型/日期/文件名组织比如avatar/2025-03/1001_avatar.jpg找问题的时候一目了然。MinIO前端访问直接用预签名URL有效期默认设30分钟到期自动失效防止文件链接被到处传播。下载合同、导出发放记录这些接口统一走后端再签名不直接暴露桶权限。4. 前端工程化与联调部署细节Vue端的工程化决定了一个后台管理系统到后期好不好维护。很多项目代码写到后面页面是能跑但路由乱得一塌糊涂组件全塞在views目录里状态管理到处是重复逻辑。4.1 Vue3后台管理项目搭建与权限路由前端工程结构我按功能模块组织src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── directives/ # 权限指令 ├── router/ # 路由配置 │ ├── index.js # 静态路由 │ └── dynamic.js # 动态路由生成逻辑 ├── store/ # Pinia状态管理 ├── views/ │ ├── employee/ # 员工管理 │ ├── attendance/ # 考勤管理 │ ├── salary/ # 薪资管理 │ ├── auth/ # 权限管理 │ └── dashboard/ # 工作台 └── utils/ # 工具函数动态路由的实现有一个很容易出错的细节通过addRoute动态添加的路由在页面刷新之后全部丢失。因为Vue实例刷新后重新创建动态路由的添加逻辑只发生在登录时。解决方案是在路由的全局前置守卫里做一个判断如果用户已登录且store里的动态路由栈为空就重新从后端拉菜单数据、添加路由、然后执行next({...to, replace: true})重新进入当前路由。这个逻辑不加刷新页面必现404或白屏。4.2 联调过程中的跨域与请求封装开发环境的前端请求地址是http://localhost:5173Vite默认端口后端网关是http://localhost:8080直接请求肯定跨域。Vite的代理配置如下// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }生产环境不用Vite用Nginx做反向代理配置思路一样。这里有个容易双重跨域的场景请求从浏览器到NginxNginx转发到网关如果网关CORS配置里把allowedOrigins写死成前端域名配合Nginx同源转发是没问题的但如果Nginx没有配置重写请求到网关时带着初始Origin网关又没配CORS浏览器就会报跨域错误。排查跨域问题不要只盯着浏览器要一层层看Nginx日志和网关日志。请求统一封装在axios的拦截器里请求拦截器自动加Authorization头响应拦截器统一处理错误码。后端返回给前端的错误结构必须统一我定了标准格式{ code: 200, message: 操作成功, data: {} }业务异常Code位从20001开始认证过期固定是40101前端拦截器检测到40101自动跳转到登录页并清空用户状态。4.3 前端构建、Nginx与网关的配合前端的打包部署走了纯静态方案Vue构建产物直接放到Nginx的html目录不塞进SpringBoot的static目录。好处很明显前端静态资源和后端接口彻底分离前端更新发布不影响后端服务后端重启也不影响前端页面访问。Nginx配置同时承担了静态资源服务、Gzip压缩和接口反向代理server { listen 80; server_name hr.example.com; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 反向代理到网关 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # Gzip压缩 gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1k; }try_files必须写否则前端路由使用history模式时刷新某个子路由页面会直接404。这一行配置是Vue单页应用部署的生死线。5. 开发环境搭建与常见问题排查实录写代码只是整个项目的一部分把环境搭起来、保证部署交付才是完整闭环。我把环境搭建和实际遇到的高频问题完整记录下来。5.1 从零搭建IDEA开发环境我在IntelliJ IDEA上搭建这套微服务开发环境的步骤JDK安装Spring Boot 3.x需要JDK17我用的OpenJDK 17IDEA里配置Project SDK选17。Maven配置用Maven 3.9.x阿里云镜像源必须配好不配镜像源光拉Spring Cloud Alibaba的依赖都能急死人。在settings.xml里设置mirror指向https://maven.aliyun.com/repository/public。Nacos启动下载Nacos Server 2.3.x单机模式启动命令startup.cmd -m standalone。注意Nacos 2.x内置了数据库Derby实际生产要切换到MySQL存储在application.properties里配置数据库连接。MySQL和RedisMySQL 8.0Redis 6.x本地跑通认证和业务接口依赖这两个基础组件。IDEA启动多服务在IDEA的Run/Dashboard里把六个服务的启动类加入分组方便一键启动。IDEA 2024及以上版本对Spring Boot多服务启动支持得非常丝滑运行时可以看每个服务的启动日志端口冲突一目了然。启动顺序有讲究必须按这个顺序来Nacos先启动然后启动auth-service等auth-service注册成功后再启动其他服务因为其他服务启动时需要连Nacos获取服务列表。顺序乱了第一次调用Feign会报Load balancer does not contain an instance for the service。5.2 部署架构与服务编排生产环境我用了Docker Compose编排服务器配置不用太高2核4G起步就行。运行节点包括服务端口说明Nginx80前端静态资源、反向代理Gateway8080API网关所有请求入口Nacos8848注册中心、配置中心MySQL3306六个业务库共用一个实例生产可考虑分库分机器Redis6379缓存、分布式锁、会话MinIO9000文件存储六个微服务8081-8086各业务服务Docker Compose里配置好网络容器间用服务名互相访问。注意有些镜像源在国内拉取可能超时建议提前把镜像pull到服务器本地或者配置registry mirror。5.3 常见问题排查速查表实际开发部署过程中我整理了一些出现率极高的坑列成一张表供参考问题现象根本原因解决方案服务启动后自动退出无报错日志端口冲突或内存太小被OOM Kill检查端口占用Docker增加-Xmx参数Feign调用报404 Not Found调用的服务没启动或服务名写错检查Nacos服务列表核对FeignClient的name前端登录后刷新页面404动态路由丢失没有重新加载菜单在全局前置守卫里增加动态路由恢复逻辑跨域报错CORS policy网关CORS配置和Nginx转发冲突统一只在网关层配CORSNginx只做转发不配CORSRedis连接池爆掉分布式锁代码里没有正确释放锁用Redisson的isHeldByCurrentThread判断后释放MinIO访问URL打不开文件上传成功但bucket权限为private使用预签名URL或给bucket配置只读策略数据库连接数被占满慢SQL或连接池泄漏开启MySQL慢查询日志排查一段时间内的慢SQL时间字段显示8小时偏差Jackson序列化时间用了默认时区在application.yml统一配置time-zone: GMT8Seata AT模式回滚失败数据库表缺少undo_log表每个业务库都要建Seata的undo_log表分布式锁加锁成功之后finally里释放锁报unlock failed十有八九是加了锁的线程和释放锁的线程不是同一个线程Redisson底层会校验线程持有关系这也是为什么isHeldByCurrentThread判断那么重要。6. 项目复盘与经验总结整个项目从启动到交付前后用了小半年。第一个月在做技术选型和整体架构第二个月把注册中心、网关、认证服务跑通第三个月开始铺业务模块最后两个月边开发边解决各种联调问题。回头复盘有些感触想分享。单体架构简单但不是越简单越好微服务复杂但也不是越花哨越高级。判断标准只有一个你的业务体量和发展阶段能否支撑这个复杂度。如果只是为了面试写“微服务架构”我劝你适可而止面试官多问两句就知道你有没有真正踩过坑。如果公司业务确实在扩张模块边界确实日益模糊那微服务的重构越早做越好。做这类系统的技术细节很多但我最深的体会是一个能落地的系统决定成败的往往不是技术亮点而是那些不起眼的规范。统一返回结构、统一异常处理、统一日志格式、固定代码分支流程这些琐碎的约定才是团队协作顺畅的基础。最后分享一个小技巧本地开发时给每个服务加logging.level.com.hr.*: DEBUG打印SQL日志和Feign调用日志排查问题效率提升好几倍。生产环境把日志级别调回INFO避免大量刷日志拖垮磁盘。这套配置在Nacos配置中心统一维护每个服务都可以直接引用不用各自写一遍。如果你准备照着这个架构自己做一套我的建议是从employee-service做起选一段最简单的员工列表接口把注册中心、配置中心、数据库、Redis、网关全部串通然后逐步增加业务模块。这样每一步都有反馈遇到问题不至于一下子面对整个系统。踩过一遍坑之后这套分布式架构心里的底稿就清晰了再回头写别的管理系统基本就是依葫芦画瓢的功夫。
返回列表