
前阵子我们把一套社区残联服务平台从单体架构整体升级成了微服务架构具体技术栈是 SpringBoot Vue SpringCloud阿里系生态系统服务的对象是社区残障人士、街道残联专干、社区网格员和志愿者。整个过程做下来比较深的体会有两点一是这类民生服务系统的业务复杂度并不低尤其是审批流、材料文件、库存扣减这些环节稍不注意就会出乱子二是残障人群的实际使用习惯和普通 C 端用户很不一样前端交互不能想当然。这篇文章就围绕这个平台的完整落地过程来写。内容包括微服务怎么拆、业务表怎么设计、分布式事务和分布式锁在哪些真实场景用上了、Vue 端动态路由和无障碍体验怎么改以及本地联调和国产化适配时踩过的一堆坑。对准备做微服务项目的 Java 开发、负责政务民生类系统的团队或者想了解 Vue 无障碍改造的朋友应该都能直接找到能用的东西。1. 项目定位与整体架构设计1.1 需求盘点这个平台到底要解决什么问题先说业务背景。残障人士办理残疾人证相关业务以前基本靠线下跑社区交材料、街道盖章、区里审批一个流程走下来行动不便的群众要跑好几趟。基层残联工作人员也很痛苦纸质材料多、统计口径乱、后期核查困难。我们接到的需求就是做一个覆盖区、街道、社区三级的残联服务平台把残障人士档案管理、困难救助申请、辅助器具申请、康复训练预约、就业帮扶信息发布、志愿者服务对接这些业务全部线上化。从技术角度盘点这个系统看起来是 CRUD但实际没那么简单。救助申请走的是多级审批流每一步都有状态流转辅助器具申请涉及库存并发场景下不能超发材料文件有图片、PDF、视频需要统一的对象存储还有政策公告的全文搜索、短信通知、统计报表。角色也比普通系统多残障用户、家属代办人、社区网格员、街道残联专干、区级管理员、志愿者各自的操作范围和可见数据完全不同。团队只有六个人前端两人、后端四人工期五个多月。这种规模和复杂度下做不做微服务、拆到什么程度一开始是有争议的。实际拍板方案是按业务域拆成八个微服务核心流程走分布式事务和可靠消息前端用 Vue3 全家桶网关统一鉴权和限流。下面把这套设计拆开讲。1.2 为什么选微服务以及服务边界怎么划很多人拿到这类项目会想一个单体应用加个定时任务不就够了确实如果系统只有三五个模块、十来张表单体是最优解。但我们的情况是业务域之间耦合度低、数据边界清晰、后续还要接微信公众号、小程序、大数据统计平台单体部署意味着任何一个模块发版都要全量重启一个慢 SQL 就可能拖垮审批接口。微服务的拆分原则我比较认同一句话围绕业务能力而不是围绕技术分层。我们没有按 controller、service、dao 来拆而是按业务域拆。最终落地的服务清单如下服务名核心职责独立数据库关键依赖gateway-server统一入口、路由转发、JWT 鉴权、限流无Nacosuser-service账号体系、残障档案、家属关系user_dbRedisassistance-service困难救助申请、多级审批、救助金发放记录assistance_dbRedis、MQdevice-service辅助器具目录、库存、申请发放device_dbRedis、MQrehab-service康复机构管理、训练预约、服务评价rehab_db无job-service就业岗位发布、投递、对接记录job_db无volunteer-service志愿者注册、活动发布、服务时长volunteer_db无notify-service短信、站内信、微信模板消息推送notify_dbMQfile-service文件上传下载、MinIO 对象存储集成file_dbMinIO服务与服务之间原则是能异步就异步必须同步调用才用 OpenFeign。比如救助申请提交后需要给申请人发通知这明显是异步场景走 MQ 更合适但审批时查询残障档案等级是强依赖的同步场景用 Feign 调用 user-service。这个拆分粒度我们复盘过觉得还是合适的。真正容易翻车的不是拆得不够多而是拆太细。见过有人把用户模块都拆成用户基础服务、用户扩展服务、用户标签服务三个服务结果查一次用户信息要调三次接口性能和心智负担都炸了。我们的原则是一个服务至少能对应一个完整的业务闭环表数量在十张以上才值得单独拆出来。1.3 技术选型稳定压倒一切技术栈的选择没什么花活就是当前 Spring 生态最成熟的一套组合微服务基础设施Nacos 做注册中心和配置中心Gateway 做网关OpenFeign 做服务间调用Sentinel 做流控降级。基础框架Spring Boot 2.7.x 系列。这里特意没追新版本SpringBoot 版本太高反而会带来一堆兼容性问题比如某些第三方 starter 还没适配、配置文件写法变了、和 Spring Cloud 组件版本对不上。锁定 2.7 配合固定的 Spring Cloud Alibaba 版本后续部署和排查都会省心很多。前端Vue3 Vite Element Plus Pinia构建速度和开发体验都比 Vue2 Webpack 好一个档次。数据层MySQL 8.0 为主库Redis 做缓存和分布式锁MinIO 做对象存储消息中间件用了 ActiveMQ因为现场环境要求实际选型时 RabbitMQ 或 RocketMQ 也完全没问题。搜索政策公告和岗位信息的全文检索用了 HanLP 做中文分词配合 MySQL 的全文索引初期阶段足够用没必要直接上 Elasticsearch 增加运维负担。这套选型最大的特点就是没有冷门组件社区资料多招人也好招。后面金仓数据库适配那次折腾也是因为国产化环境要用国产数据库那部分放在最后一章细说。2. 业务与数据模型设计2.1 救助申请审批流状态机怎么设计不容易乱先拿救助申请这个核心流程举例。残障用户在线提交困难救助申请填写困难类型、家庭收入情况上传低保证明、病历材料等附件然后走社区网格员初审、街道残联专干复核、区级终审终审通过后进入公示和发放环节。这个流程如果只用一张表和几个 update 语句硬怼后面查“某人的申请现在到哪一步了”会非常痛苦。我们的做法是申请主表只存当前状态状态流转的每一步细节单独存到审批记录表。CREATE TABLE t_assistance_application ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(32) NOT NULL COMMENT 申请编号, user_id BIGINT NOT NULL COMMENT 残障用户ID, profile_id BIGINT NOT NULL COMMENT 残障档案ID, difficulty_type TINYINT NOT NULL COMMENT 困难类型1低保 2特困 3其他, apply_amount DECIMAL(10,2) NOT NULL COMMENT 申请金额, status TINYINT NOT NULL COMMENT 当前状态10草稿 20待初审 30待复核 40待终审 50已通过 60已驳回 70已终止, current_node VARCHAR(32) COMMENT 当前审批节点, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE t_approval_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型assistance/device/rehab, biz_id BIGINT NOT NULL COMMENT 业务ID, from_status TINYINT, to_status TINYINT NOT NULL, operator_id BIGINT NOT NULL, operator_role VARCHAR(32) NOT NULL, comment VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );状态用数字存储状态机的流转逻辑放在 service 层统一校验。比如待初审状态只能由社区网格员操作而且只能流转到待复核或者已驳回其他任何非法操作都会直接抛错。这样设计之后做审批进度展示时只要查一下 t_approval_record 就能画出完整的时间线出问题追溯责任人也非常方便。这里要特别提醒一点状态字段不要直接用 varchar 存中文描述比如“审核中”“已通过”。一方面 IO 和存储开销大另一方面很容易出现同一个意思两种写法比如“已通过”和“审核通过”后面统计报表全对不上。数字枚举加代码注释再维护一张字典表才是正规做法。2.2 残障档案表的关键细节类别、等级和数据敏感度残障档案是整个平台的数据底座设计得好不好直接影响后面所有业务。残疾人证的要素比较固定残疾类别分为视力残疾、听力残疾、言语残疾、肢体残疾、智力残疾、精神残疾、多重残疾七类残疾等级分为一级到四级其中一、二级属于重度残疾享受的政策倾斜明显不同。t_disability_profile 表至少要有这些字段CREATE TABLE t_disability_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, disability_category TINYINT NOT NULL COMMENT 1视力 2听力 3言语 4肢体 5智力 6精神 7多重, disability_level TINYINT NOT NULL COMMENT 残疾等级1-4级, disability_cert_no VARCHAR(32) NOT NULL COMMENT 残疾人证号, guardian_name VARCHAR(64) COMMENT 监护人姓名, guardian_phone VARCHAR(20) COMMENT 监护人手机号, household_type TINYINT COMMENT 1低保户 2特困户 3一般户, is_heavy TINYINT DEFAULT 0 COMMENT 是否重度残疾1/2级为1, encrypted_medical_info TEXT COMMENT 医疗信息AES加密存储, community_code VARCHAR(16) NOT NULL COMMENT 所属社区编码 );这个表有几个细节值得展开说。残疾人证号是有编码规则的前几位代表地区、中间是类别和等级后面是序列号录入时要加格式校验能挡住一大批手工录入错误。身份证号、手机号属于个人信息库表里可以存明文方便查询但接口返回时一定要脱敏比如 110***********1234。医疗信息这类更敏感的数据入库前用 AES 加密查询时再解密避免数据库文件泄露造成二次伤害。社区编码这个字段尤其重要它是数据权限的天然边界。社区网格员登录后我们要求后端在 SQL 层强制拼上 community_code 过滤条件而不是把数据全查出来再在内存里过滤。这既是性能问题更是安全问题的底线。曾经有人图省事在 service 里做判断结果慢查询翻倍后来全部改为 SQL 层硬过滤。2.3 RBAC 权限模型五类角色如何做到按钮级控制这个平台的用户角色比一般系统多残障用户、家属代办人、社区网格员、街道残联专干、区级管理员后面还加了志愿者。直接写死角色判断代码是天坑我们用的是标准的 RBAC 模型用户-角色-菜单权限-按钮权限。菜单权限控制到路由级别前端拿到用户可访问的菜单树后动态注册路由按钮权限控制到具体操作比如“审核通过”按钮只有街道残联专干和区级管理员能看到。这个模型的落地方式在第四章讲动态路由的时候细说这里先讲后端权限校验。后端不信任前端的任何判断网关层负责 JWT 令牌的解析校验业务层接口再用自定义注解RequireRole(street_admin)做二次校验。这里推荐一个实用做法角色不要定义得太细否则后面每个接口都要配一堆注解。我们定义了五个基础角色每个角色在数据库里配置菜单和按钮权限加新接口时只需要在权限表里插入对应记录不用改代码。安全方面还有一个容易忽略的细节审批操作的幂等性。网络抖动时用户点了两次“提交申请”后端可能收到两个一模一样的请求导致生成两条申请单。方案很简单前端按钮点击后立即置灰后端在网关层用 Redis 做了幂等键校验同一个用户相同业务编号在短时间内只处理一次。实测效果很好上线后重复申请的问题基本绝迹。3. 分布式事务、锁与缓存落地3.1 救助申请的最终一致性我们用了本地消息表微服务落地第一个绕不开的问题是分布式事务。我们的救助申请流程里有一个典型场景assistance-service 保存申请单之后需要同步做三件事——给 user-service 发送一条“档案等级已应用到申请单”的更新请求给 notify-service 发一条待初审通知给 device-service 预扣一笔救助额度。这三个动作如果直接同步调用任何一个服务抖动都会导致业务失败。方案选型时我们认真对比过 Seata AT 模式、TCC、本地消息表和 MQ 事务消息。结论是这个场景根本不需要强一致允许短时间不一致只要最终能对齐就可以了所以放弃了 Seata。强一致方案看着省心实际上对数据库侵入大性能损耗明显还要额外维护全局锁对这类民生系统性价比太低。最终用的是“本地消息表 定时任务补偿”这个经典方案。具体流程是assistance-service 在本地事务里同时插入申请单主记录和一条消息记录消息状态为待发送。本地事务提交成功后立即尝试把消息发送到 MQ 的某个 topic。如果发送成功更新消息状态为已发送如果应用宕机或 MQ 不可用消息状态一直停留在待发送。定时任务每 30 秒扫描待发送且超过 1 分钟的消息重新发送。超过重试次数上限的标记为失败并告警人工介入处理。CREATE TABLE t_local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL, biz_id VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待发送 1已发送 2已完成 3失败, retry_count INT DEFAULT 0, next_retry_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这套方案最大的优点是简单可靠不用引入额外组件普通团队完全 hold 住。要注意的是消费端必须做幂等处理因为定时任务重发会导致重复消息。我们要求在 notify-service 消费时先根据 biz_id 查一下是否已经处理过处理过就直接确认不再重复发短信。3.2 辅助器具库存扣减Redis 分布式锁的正确姿势辅助器具申请里有一个类似秒杀的场景某个热门型号的轮椅放出来二十个名额申请时间一到可能同时有上百个请求打进来。如果用数据库扣库存简单这么写UPDATE t_device_stock SET stock stock - 1 WHERE device_type_id ? AND stock 0;这条 SQL 本身用行锁可以防超卖但我们的申请链路不只是扣库存还要创建申请记录、检查残障等级是否符合、返回编号给前端。如果全部依赖数据库行锁一方面锁持有时间太长另一方面在高并发下数据库压力很大。所以采用了 Redis 预扣库存 异步落库的模式用 Redisson 分布式锁保证同一型号的库存扣减串行执行。核心代码给大家看一个规范版本Autowired private RedissonClient redissonClient; public void applyDevice(String deviceTypeId, Integer applyCount, Long userId) { String lockKey lock:stock: deviceTypeId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { // 5秒内拿不到锁就返回避免线程堆积 locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(申请人数过多请稍后重试); } Integer currentStock getRedisStock(deviceTypeId); if (currentStock applyCount) { throw new BizException(库存不足); } // Redis预扣库存 deductRedisStock(deviceTypeId, applyCount); // 发送异步消息最终落库并生成申请记录 mqSender.sendDeviceApplyMsg(deviceTypeId, applyCount, userId); } finally { if (locked) { lock.unlock(); } } }这里有几个反直觉的坑必须说一下。第一只调用lock.lock()而不设置过期时间如果业务执行过程中应用宕机锁会一直不释放其他线程全部卡死。Redisson 的看门狗机制会自动续期但最稳妥的做法还是 tryLock 时主动设置 leaseTime。第二delete 锁的时候一定要判断是不是自己的锁否则 A 线程的锁刚过期B 线程拿到锁A 线程执行完把 B 的锁删了锁就形同虚设。Redisson 内部对这个问题封装得很好所以建议直接用现成库别自己拿 SETNX 写一套。第三锁的粒度要按设备型号来不要用一个全局锁把所有型号的申请串行化否则并发能力直接报废。库存预扣之后用户取消申请或者审批不通过要记得回补 Redis 库存并发送异步消息更新数据库这一环漏了必出大事故。我们当时专门写了补偿定时任务扫描超过 24 小时未完成审批的申请单自动回补库存。3.3 热点数据缓存和全文搜索不能只会用 Redis这类平台对性能要求不算极致但对可用性要求很高。残障人士在社区网点办理业务时如果页面转圈超过三秒体验就很差了。我们主要对三类数据做了缓存残障类别和地区等基础字典、政策公告列表、各服务首页的统计数据。缓存更新策略上基础字典这种低频变更数据用最直接的“更新时删除缓存”策略下一次请求再回源数据库加载。公告列表会频繁被查询我们缓存了五分钟后台编辑公告后主动调用清理接口删除缓存。这里要特别小心缓存穿透、击穿、雪崩三个问题。穿透指的是查询一个不存在的 key每次都会打到数据库我们用一个空值缓存加布隆过滤器拦截击穿指的是某个热点 key 瞬间失效大量请求打到数据库用互斥锁设置缓存时只有一个线程去查库雪崩指的是大量 key 同一时间失效解决方法是过期时间统一加一个随机数打散失效时间。搜索这块我没上 ES前期数据量没那么大。政策公告的全文搜索用 HanLP 分词查询时先对搜索词做分词再拼 SQL 用 LIKE 匹配标题和正文。实际效果比那种直接 LIKE %关键词% 的写法好很多比如搜“辅具”也能匹配到包含“辅助器具”的公告。下面这段是集成方式import com.hankcs.hanlp.HanLP; ListString words HanLP.segment(userInput) .stream() .map(term - term.word) .collect(Collectors.toList()); // 把分词结果拼成动态SQL例如 titile LIKE %辅具% OR content LIKE %辅助器具% String fuzzySql words.stream() .map(w - title LIKE % w % OR content LIKE % w %) .collect(Collectors.joining( OR ));等以后公告数据量涨到几十万条再把索引迁移到 Elasticsearch服务接口基本不用改。这是微服务的好处之一搜索可以独立演进。4. Vue 前端工程与无障碍体验改造4.1 前端工程搭建Vue3 和依赖管理的那些事前端选型是 Vue3 Vite Element Plus没用 TypeScript因为团队更熟 JS而且这类后台系统 TS 的收益没有那么大。工程结构按模块划分src/ api/ # 按后端服务拆分的接口模块 assets/ components/ # 公共组件含无障碍相关组件 router/ # 静态路由 动态路由注册 stores/ # Pinia 全局状态 views/ # 页面组件 assistance/ # 救助申请模块 device/ # 辅具申请模块 profile/ # 残障档案模块Vue3 Vite 的依赖安装总体比 Vue2 时代舒服很多但有几个坑还是要提醒。Node.js 版本不能太老Vite 4 以上要求 Node 16 以上有的老项目环境是 Node 12装依赖就会报错需要先升级 Node。npm install 时看到 peerDependencies 冲突别急着--force硬怼先看是哪个依赖版本不兼容比如 Element Plus 对 Vue 版本有严格范围乱装很容易出现组件注册了但样式不生效的问题。团队开发时建议统一锁定 package-lock.json 提交到 git否则每个人本地安装的依赖版本不一样可能 A 同学跑得好好的B 同学一启动就白屏。4.2 动态路由菜单权限怎么做到不刷新页面也能生效后台管理系统的权限控制最怕的就是前端把路由表写死然后靠v-if判断角色显示菜单。这么做的后果是用户手动改 URL 就能访问无权限页面。我们的方案是动态路由登录后请求后端接口拿到当前用户的菜单树再通过 router.addRoute 动态注册。后端返回的菜单结构大致长这样[ { path: /assistance, name: Assistance, component: assistance/index, meta: { title: 救助审批, icon: Document }, children: [ { path: list, component: assistance/list, meta: { title: 申请列表 } }, { path: audit, component: assistance/audit, meta: { title: 待审事项, permission: assistance:audit } } ] } ]前端要做的工作是把这个 JSON 转换成 Vue Router 能识别的 RouteRecordRawconst compMap { assistance/index: () import(/views/assistance/index.vue), assistance/list: () import(/views/assistance/list.vue), assistance/audit: () import(/views/assistance/audit.vue) } function buildRoutes(menus) { return menus.map(menu ({ path: menu.path, name: menu.name, component: compMap[menu.component], meta: menu.meta, children: menu.children ? buildRoutes(menu.children) : [] })) } const routes buildRoutes(menuData) routes.forEach(route router.addRoute(route))这里有个容易踩的坑后端返回的 component 字段是字符串必须在前端维护一个字符串到组件对象的映射表否则 Vue Router 无法懒加载组件。另外刷新页面时会重新走一遍登录态校验和菜单加载流程所以路由守卫里要判断 Pinia 或 sessionStorage 中是否已存在菜单数据不存在则先拉取再 addRoute否则会出现刷新后白屏。按钮级权限我用了一个自定义指令模板上这样用el-button v-permissionassistance:audit审核通过/el-button指令内部检查当前用户的按钮权限列表没有权限就移除元素。这套方案比在每个组件里写if (hasPermission())清爽得多权限点集中管理加新按钮时只需在权限表里配置。4.3 无障碍体验残障人士用得动才是好系统这个平台的用户和普通后台管理系统的用户完全不同。他们中不少人有视力障碍、肢体障碍或者认知障碍年龄也偏大。如果只按普通 Web 应用的标准做等于没做。我们在无障碍这块花的精力比做一个华丽的数据大屏多得多。第一件事是字体和颜色。页面顶部放了无障碍工具条提供大字号模式和高对比度模式。大字号模式通过给根节点动态添加 class把基础字号从 14px 提到 18px注意不能只改 html必须用 rem 或 em 单位才能联动所有子元素。高对比度模式把文字全部加粗、背景色改为黑黄高对比配色方便低视力用户。第二件事是语音朗读。网页上用 Web Speech API 的 SpeechSynthesis 接口给关键操作提示加了朗读功能。比如“提交成功申请编号为 20260615001请等待社区工作人员初审”读出来比弹窗提醒更有帮助。实现也不复杂function speak(text) { if (!(speechSynthesis in window)) return const utterance new SpeechSynthesisUtterance(text) utterance.lang zh-CN utterance.rate 0.9 window.speechSynthesis.speak(utterance) }第三件事是键盘操作。视力障碍用户很多不依赖鼠标整个页面必须能够完全用键盘操作。重点是三点焦点顺序要符合阅读逻辑Tab 键切换时焦点不能乱跳弹窗打开时焦点要自动落入弹窗关闭后要回到触发按钮所有表单校验错误提示不能只靠红框颜色必须在字段下方给出文字说明最好同时朗读出来。第四件事是减少跳转。残障用户在申请表填写过程中经常要查证件信息我们做了一个右侧抽屉可以随时调出残障档案摘要不用离开当前填表页面。这个小功能用户反馈非常好比任何炫酷特效都实用。4.4 文件预览和视频播放里的几个实际问题残联平台涉及大量材料上传和查看包括身份证照片、低保证明 PDF、康复训练指导视频。文件预览这块有几个问题网上问得多我们也都踩过。PDF 预览很多人问 Vue 里能不能直接显示 PDF。答案是能但别用浏览器插件。我们的做法是 file-service 生成 MinIO 签名 URL前端拿到 URL 后塞进 iframe 的 src 或直接用 pdf.js 渲染。生产环境推荐用 pdf.js因为它可以控制页数、加载进度和操作按钮体验比裸 iframe 好得多。要注意签名 URL 有有效期默认一小时预览页打开时间太长会失效需要提醒用户刷新。康复指导视频线下康复机构的指导视频有些是 m3u8 格式的直播录播流。Vue 里播放 m3u8 不能直接放video srcxxx.m3u8Android 和桌面浏览器都不原生支持。最省事的方案是用 hls.js 封装一个播放组件import Hls from hls.js function playM3u8(videoElement, url) { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(url) hls.attachMedia(videoElement) this.hlsInstance hls } else if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { // iOS Safari 原生支持 HLS videoElement.src url } }播放器组件销毁时要hls.destroy()否则会持续拉流白耗带宽。文件上传还有个很容易忽略的问题默认的 axios 上传请求走网关网关如果设置了超时时间一个大文件传一半会被切断。我们最后是在网关的转发规则里对 /file-service/upload 路径单独设置了较大的超时时间同时前端做了分片上传超过 20MB 的文件切成 5MB 一片上传服务端再合并。不要觉得分片上传麻烦真传一个 200MB 的视频材料时不分片没人扛得住。5. 联调部署与问题排查实录5.1 本地联调Idea 多服务启动与端口配置微服务项目在本地开发时最大的痛点是启动顺序和配置管理。我们这边团队统一的做法是先把 Nacos 启动起来然后在 Idea 里创建一个 Compound Run Configuration把八个服务加上前端开发的代理一次性全部启动。这样做的核心价值是防止有人只启动了部分服务联调时出现“明明我改了代码怎么不生效”的误会。每个服务的启动端口是通过 Nacos 配置中心动态下发还是写死在各自 application.yml 里我们的经验是本地开发配置写死在本地配置文件不要依赖配置中心下发否则启动时 Nacos 还没就绪端口不对查半天。比如 user-service 本地跑 8081assistance-service 跑 8082各自通过 Idea 的 Environment variables 注入SERVER_PORT8081 NACOS_ADDR127.0.0.1:8848前端开发时 Vite 代理转发到网关端口 8080server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }网关统一前缀为 /api再根据路径前缀路由到对应服务例如 /api/assistance 开头走 assistance-service。这样前端永远只认一个网关地址后端服务怎么拆都对前端透明。跨域问题也在网关这一层用全局 CORS 配置解决不要在业务代码里加 CrossOrigin否则不止一个入口时乱套。5.2 金仓数据库读写分离适配一段值得记录的折腾项目做到后期客户提了个要求系统需要部署到国产化环境数据库要兼容人大金仓。这意味着要把原来连 MySQL 的 DAO 层切换到金仓数据库。我们的经验是与其说这是技术问题不如说是规范问题。如果项目一开始就严格使用 JPA/MyBatis 的标准方言不滥用 MySQL 特有语法迁移成本会小很多。真正出问题的地方有三个。第一是分页语法MySQL 的LIMIT ? OFFSET ?在金仓里要改成LIMIT ? OFFSET ?其实兼容但金仓更推荐FETCH FIRST ? ROWS ONLY的写法这取决于配置是否开启 MySQL 兼容模式。第二是自增主键MySQL 的AUTO_INCREMENT到金仓要改成序列加默认值DEFAULT nextval(seq_xxx)。第三是函数名差异比如日期格式化函数、字符串拼接函数两边的写法不完全一致。读写分离是另一个独立需求。我们的实现方式是 Spring 的 AbstractRoutingDataSource配合 AOP 切面方法名以 get、select、find、list 开头的走从库其余走主库。但要注意一个隐藏得很深的坑同一个事务里先写了数据又立刻去查如果查询走了从库可能因为主从同步延迟读不到刚写的数据。解决办法是只要方法上有 Transactional读写分离切面就强制走主库保证事务内读己之写。这个规则必须写成硬性规范否则测试环境数据量小看不出问题生产环境延迟一大就出事故。金仓读写分离的配置和 MySQL 类似主从各配一个数据源切面逻辑不变。真正要花时间的是全量回归测试特别是那些写了复杂关联查询的报表接口SQL 方言差异导致的报错只能在测试阶段暴露。5.3 常见故障速查版本冲突、慢查询、锁失效最后整理一份我们实际遇到的故障排查表都是真实案例按频率排序症状根因解决办法服务启动失败报 NoSuchMethodErrorSpring Boot 与其他 starter 版本不兼容用 mvn dependency:tree 查依赖冲突统一版本到 BOM 管理不要盲目升级到 SpringBoot 最新版两个服务之间 Feign 调用偶发超时未配置合理的超时时间默认 1 秒太短在 Feign 配置里设置 connectTimeout 和 readTimeout一般 3-5 秒救助申请审批后通知没收到MQ 消息发送了但消费端异常看死信队列和消费日志本地消息表定时任务兜底重发某个热点接口突然变慢Redis 缓存 key 集中过期引发雪崩过期时间加随机 0-300 秒热点 key 打散到不同过期时段前端上传大文件超时网关默认超时时间太短调整网关转发超时前端做分片上传多人同时申请辅具库存被扣成负数Redis 预扣和数据库回写未保证原子性用 Redisson 分布式锁异步消息落库后二次校验库存菜单权限改了不生效前端路由表还是旧的路由缓存重新登录拉取菜单开发环境不要禁用动态路由刷新版本冲突这个坑单独说一下。我们早期有个同事图新把某个服务的 Spring Boot 版本升到 3.x结果 Nacos 客户端和 Sentinel 组件的 class 全冲突了项目直接起不来。后来全团队统一规定中间件 SDK 版本和 Spring Boot 版本必须由架构师锁定在公共 BOM 里任何人不得单独升级。微服务最忌讳的是各服务依赖版本百花齐放统一版本就是统一行为排查问题才能靠一套经验包打天下。5.4 稳定性保障Sentinel 流控和降级兜底残联服务平台虽然不像电商大促那样流量爆炸但每年的助残日、政策集中申报期会有较大的瞬时流量。我们提前用 Sentinel 给几个核心接口配了流控规则比如救助申请提交、辅具申请这两个接口QPS 超过阈值直接返回“系统繁忙请稍后重试”避免数据库被打垮。这个策略看着简单实际救了团队一次。还有一个容易忽略的地方是 Feign 调用的降级。user-service 挂了assistance-service 如果直接报错整个申请流程就断了。我们给所有 Feign 接口配置了 fallback 工厂在降级方法里返回一个能区分“服务不可用”的默认值同时记录告警日志。残障用户在页面上看到的是“档案信息暂时无法加载请稍后刷新”而不是一大串报错堆栈。对普通用户来说稳定的系统给人的安全感远大于功能列表上的炫技。6. 项目复盘与经验沉淀整个项目做完我最想分享的一个体会是微服务不是目的能稳定跑起来并让业务提效才是目的。技术选型上我们全部选成熟组件不追最新不玩概念核心是为了减少意外。业务建模上我们花了大量精力设计状态机和权限模型这些是系统长期稳定的骨架。分布式事务和锁这类硬核问题我们只在真正需要的地方才用比如库存预扣和补助发放其他地方尽量用异步和最终一致来化解复杂度。这套平台后续还能延伸的方向不少。可以对接微信公众号服务号把申请进度、审批通知主动推送到用户手机上可以做志愿者服务时长的区块链存证增加可信度也可以把残障数据做统计分析形成区域残障人群服务报告辅助业务单位做资源配置。最后再给同行的朋友一句建议做这类面向特殊群体的系统代码写得好不好当然重要但更关键的是你能不能真正理解他们的使用场景。一个布局混乱但功能完整的页面和一个对视力障碍用户友好但功能简单的页面在这类项目里后者的价值可能更高。技术最终要落到对人的关怀上这才是这行最有成就感的部分。