
做这行最深的体会是系统的问题从来不是替代码背锅而是替架构决策背锅。去年我们团队接手信贷产线的用户信用评估系统改造第一版是单体应用后来外部数据源从三家涨到十三家评分策略每月迭代查询高峰时单机扛不住慢查询拖垮整个Dubbo接口的事故一个月出了三次。这才下定决心基于SpringBoot Vue Spring Cloud微服务分布式架构做彻底重构。这篇文章是完整复盘包含服务怎么拆、分布式事务和分布式锁怎么取舍、评估链路怎么编排、前端Vue怎么跟微服务后端对接以及实际运行时踩过的坑。适合准备做微服务改造的团队以及想了解信用评估类系统从单体迁到分布式的读者。1. 信用评估业务为什么非拆不可三个信号出现时就该动手很多人一提微服务就激动但我的态度一向是能单体就别拆。信用评估类系统有一个特点它天然是数据密集型 规则密集型 高并发查询并存。这种业务如果早期勉强运行后面大概率会被三座山压垮数据源大山、规则大山、流量大山。1.1 单体在信用评估场景下的具体死法先说数据源。信用评估需要对接征信机构、多头借贷平台、消费流水、社保公积金、运营商数据。每个数据源的协议不同、字段不同、响应时间更是从200毫秒到3秒不等。单体里所有数据源对接代码堆在一个服务中只要有一个外部源抖动线程池被占满整站评估接口全部卡死。我遇到过最夸张一次某第三方接口超时设置为30秒高峰期两百个线程全卡在等待上用户端请求直接雪崩。再说规则。信用策略团队几乎每周都在调准入规则、额度规则、利率规则。单体版本下改一条规则要重新打包、全量发布发布窗口长、回归测试重业务方抱怨“你们上个需求要三天黄花菜都凉了”。而规则引擎如果不需要跟其他模块一起发布独立上线整个节奏就能压缩到半天。最后是流量。信用评估的查询峰值有明显的业务节奏比如账单日后三天、平台大促、新客进件活动。单体撑不住动态扩缩容要么常年高配烧钱要么峰值时四处救火。拆微服务之后我们只需要对评估链路相关的两三个服务单独扩容成本直接降了一半。1.2 我判断“可以拆”的三条标准不是我拍脑袋觉得该拆而是生产环境给出了三个明确信号独立模块的发布频率出现明显差异。用户通知模块一个月都不动一次评分策略模块每周都在动。这两个还挤在一个包里就是互相拖累。资源需求差异明显。外部数据源对接是高IO型评分计算是CPU密集内存密集混在一个进程里资源配比永远不合理。某一块故障导致全局不可用。只要出现一次就有充分的拆分理由了。打到这里拆分的条件就成立了。注意拆分不是为了炫技是业务复杂度已经超过了单体的承载极限。2. 服务边界切分从单体下钻到七个业务域的全过程都说微服务拆分难难在边界不是技术问题而是业务理解问题。信用评估系统的核心本质是“把原始数据变成决策结果”我按这个本质把系统划分为数据接入层、特征加工层、评分决策层、用户管理层、资信报告层外加网关和基础服务。2.1 七个服务的职责定义与存储边界拆分之前必须先给每个服务定死边界。我们最终保留了七个独立的Maven模块各自独立数据库实例禁止任何服务直连他人的表。服务名核心职责主要存储对外接口gateway-service统一鉴权、路由转发、限流Redis所有前端和外部调用入口user-service用户注册登录、实名认证、基本资料MySQL Redis用户信息查询、认证credit-data-service外部数据源接入、数据清洗归一、采集任务调度MySQL ES原始数据落库、统一字段输出feature-service衍生特征加工、变量计算MySQL特征批量与实时查询score-service规则引擎、评分卡模型推理、策略决策MySQL Redis完成一次评估、命中规则明细report-service信用报告组装、PDF导出、历史报告查询MySQL 对象存储报告生成、档案查询message-service短信、站内信、评估结果通知MySQL通知发送这个拆分逻辑的核心不是“按层拆”controller、service、dao各拆一套那种竖切是灾难而是“按业务域拆”。数据归属谁、变化频率怎样、谁最依赖这个数据就以谁为主体建模。比如原始征信数据只属于credit-data-servicefeature-service需要数据时一律走接口不允许共享库。2.2 Maven多模块如何组织代码层面用父子工程管理。父pom负责依赖版本统一子模块各自独立打包部署。下面是父pom里最核心的依赖管理片段dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version2.7.18/version typepom/type scopeimport/scope /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version2021.0.8/version typepom/type scopeimport/scope /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-dependencies/artifactId version2021.0.5.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement这里有个经验SpringBoot、SpringCloud、SpringCloudAlibaba三者的版本矩阵必须统一锁定否则启动时各种NoSuchMethodError教做人。我们吃过亏后来越来越稳的配置就是直接抄官方版本说明里的Release Train组合不打自定义小版本。2.3 拆分后的依赖关系服务间调用用OpenFeign注册中心用Nacos配置中心也是Nacos。调用方向严格单向gateway调用各业务服务report-service调用score-service和credit-data-servicefeature-service调用credit-data-service不允许反向调用避免形成环依赖。之所以选Nacos而不是EurekaConfig分体部署是因为小团队不想维护两套中间件。Nacos一个组件同时解决注册发现和配置管理带namespace隔离环境生产、预发、测试三个环境物理上是同一个集群但逻辑完全隔离节省运维成本。这是我们在SpringCloud全家桶里最务实的选型。3. 分布式硬骨头分布式事务、分布式锁、幂等怎么落地微服务化之后最痛的三个问题信用评估系统全占。先说结论能不用分布式事务就不用所有跨服务写操作优先设计成“聚合写入”或者“异步最终一致”分布式锁必须用成熟组件别自己写Redis SETNX裸实现。3.1 分布式事务的取舍信用评估里真正的强一致场景其实不多。盘点下来大多数业务都能通过把写操作聚合到一个服务里解决。比如“额度扣减 借款记录落库 用户信用分更新”很多团队会拆到三个服务里然后上Seata AT模式做全局事务。我改造时直接把借款记录和额度扣减合进了decision-service决策管理服务一次性本地事务搞定信用分更新走MQ异步翘起。这一刀切下去分布式事务的需求直接砍掉了80%。真正剩下的场景比如“评估完成报告生成 通知推送 外部回调”用本地消息表 RocketMQ最终一致就够了。核心流程是在业务主库的事务里写业务记录和一条消息记录。事务提交后一个定时Job扫描消息表中待发送的记录发送到RocketMQ。消息消费者处理下游业务处理成功回执更新消息状态为已发送。消费者消费失败重试N次后转入死信队列人工介入。这套方案的优点是数据一致性有兜底消息表和业务表同库同事务缺点是每次消费多一次状态查询。但对于信用评估这种异步通知场景完全够用。为什么不上Seata AT因为AT模式靠全局锁undo_log做回滚在高并发写入场景下性能损耗明显还容易在长事务里拖垮数据库连接池。我们的评估核心请求路径上不出现跨服务写自然不需要全局事务这把重锤。3.2 Redis分布式锁的正确用法信用评估系统里有两个高频分布式锁场景。第一个是“同一用户同一时间只能有一个评估任务在跑”防止刷接口和并发重复评估。我推荐用Redisson的RLock它内部实现了看门狗机制默认每10秒自动续期一次避免业务没执行完锁就过期。代码很简单Autowired private RedissonClient redissonClient; public boolean tryAcquireEvaluateLock(String userId) { RLock lock redissonClient.getLock(evaluate:lock: userId); try { // 等待2秒锁有效期30秒看门狗会自动续期 return lock.tryLock(2, 30, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }注意以上是tryLock参数写法如果换成一个参数的lock()方法Redisson会启用看门狗默认30秒续期机制不存在提前失效的问题适合耗时不可控的任务场景。第二个场景是月底批量评分任务。全量用户重算分数时多个实例如果同时执行会对数据库和外部数据源造成巨大压力。我让定时任务先抢一把分布式锁抢到的那个实例执行其他实例跳过。有个细节必须提醒Redis主从切换时分布式锁可能丢失。严格场景下用RedLock或者直接上ZooKeeper。我们的场景容忍极端情况下的重复计算计算结果一致覆盖写即可所以Redis锁够用但如果你是扣款、提现这种场景请务必认真考虑锁的可靠性问题。3.3 幂等设计微服务下接口幂等是基本素养。信用评估对外接口比如合作方推送用户信息触发预授信必须支持幂等否则重复推送会导致重复评估、重复授信。做法是外部请求带上业务流水号在网关层先查Redis。如果setnx成功说明是新请求放行如果已存在则直接返回上一次结果。// 网关过滤器中的幂等判断逻辑伪代码 String idempotentKey request.getHeader(X-Request-Id); Boolean success redisTemplate.opsForValue() .setIfAbsent(idem: idempotentKey, 1, Duration.ofHours(24)); if (Boolean.FALSE.equals(success)) { // 重复请求直接返回缓存结果或提示 return ResponseUtil.duplicate(); }MQ消费端也要幂等。办法是消费消息时先查本地消息表若该消息ID已经消费过直接ack。这个查询走主键索引代价很低非常值得做。4. 核心信用评估链路从数据采集到评分输出的完整调用栈信用评估系统的主链路是发起评估 → 并行采集数据 → 数据归一 → 特征加工 → 规则引擎判定 → 模型评分 → 策略决策 → 结果落库。这八步里最考验微服务架构功力的是“并行采集”和“规则引擎”这两个环节。4.1 异步并行数据采集信用评估要的数据源太多了征信报告、多头借贷、消费流水、运营商数据串行调用的话一个用户评估至少等10秒不可接受。所以必须并行。我在credit-data-service里维护了一张数据源配置表记录每个数据源的优先级、超时时间、最大等待时间。发起采集时用CompletableFuture并行调用。核心代码如下public DataCollectResult collect(CollectRequest request) { ListDataSourceConfig sources loadEnabledSources(request.getScene()); ListCompletableFutureDataSourceResponse futures sources.stream() .map(source - CompletableFuture.supplyAsync( () - invokeDataSource(source, request), dataSourceThreadPool)) .toList(); // 等待所有源返回但最长只等3秒 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .get(3, TimeUnit.SECONDS); // 未返回的源记录为超时后续异步补偿 return assembleResult(futures); }这里要特别强调线程池隔离。独立的dataSourceThreadPool必须用有界队列 拒绝策略核心线程数根据压测结果定我们压下来是30个核心线程、队列容量200满了之后拒绝策略走CallerRunsPolicy并快速降级为异步补偿。绝对不能用JVM默认的ForkJoinPool更不能用无界队列否则外部源抖动时整个服务内存直接被积压任务打爆。4.2 规则引擎用Groovy脚本替代Drools策略团队天天调规则如果用Drools一来学习成本高二来规则文件编译复杂。我最终选了Groovy脚本 Nacos配置中心的轻量方案。规则脚本放到Nacos配置里修改后发布即可不需要重新部署服务。评分运行时通过GroovyShell加载脚本执行。以准入规则为例// 规则命中黑名单直接拒绝 if (user.blackFlag 1 || user.loanOverdueCount 6) { return new RuleResult(false, 黑名单或严重逾期, -100) } // 规则近3月查询次数超过12次拒绝 if (feature.queryCount3m 12) { return new RuleResult(false, 近3月查询过多, -30) } return new RuleResult(true, 通过, 0)Groovy脚本天然弱类型工程师改起来门槛低Nacos改了配置秒级生效业务方测试完一次直接投产。但GroovyShell每次new开销大要缓存Script对象而不是每次解析。缓存后单次执行耗时可控制在0.5毫秒以内性能完全够用。4.3 评分模型服务与灰度score-service里跑两个东西规则引擎和评分模型。模型是Python训练完导出PMML或Java可执行的序列化文件再加载到服务里推理。模型版本要支持灰度切换线上同时加载新老两个模型文件通过配置中心的开关按用户ID尾号分流。比如尾号0-4走新模型5-9走老模型观察一周KS值和坏账率再全量切换。这个机制是整个信用评估系统上线过程中少踩大坑的关键。4.4 同步返回与异步补偿的配合主链路评估请求需要同步返回结果。设计是必查的核心数据源身份、黑名单、逾期必须返回后才出结果非核心数据源社保、公积金超时了不影响主决策标记为缺失后续异步补偿采集特征实时重算如果结果变化则触发通知。这样做的原因很简单用户体验上用户等不了10秒但我们又不想因为一个运营商数据超时直接拒绝用户。折中后效果很好核心评估P95控制在2.8秒内多数场景用户感知良好。5. Vue 端怎么和微服务后端配合认证、动态路由与打包部署细节微服务改造中前端受苦最深因为后端拆了十多个服务前端反而面对的是更大的一致性挑战。我们前端用的SpringBoot技术栈配套的Vue 3 Element Plus Pinia。这里挑三个最关键的问题说透。5.1 统一认证与token刷新所有请求必须走gateway-service前端只知道网关地址不感知任何业务服务。登录后拿到JWT我们用的AccessToken RefreshToken双token机制AccessToken有效期2小时RefreshToken有效期7天。前端axios封装时在响应拦截器里判断401service.interceptors.response.use( (response) response.data, (error) { const { response } error if (response response.status 401) { // 尝试刷新token return refreshToken().then(() { error.config.headers.Authorization Bearer getNewToken() return service(error.config) }).catch(() { router.push(/login) return Promise.reject(error) }) } return Promise.reject(error) } )刷新token接口本身要单独放白名单不能让网关的鉴权过滤器拦住形成死循环。这也是很多团队第一次做微服务前端对接时最容易踩的坑。5.2 动态路由按角色生成菜单信用评估系统的用户角色有四类系统管理员、策略配置人员、审核人员、普通用户。不同角色看的功能菜单完全不同前端用静态路由写死的话每调整一次权限就要发一次前端版本太蠢了。正确做法是登录后请求网关下的权限服务拿到当前用户的菜单树前端用addRoutes动态加入路由。Vue Router代码如下// 登录成功后动态注册路由 const modules import.meta.glob(../views/**/*.vue) const dynamicRoutes generateRoutes(menuData, modules) dynamicRoutes.forEach((route) router.addRoute(route))注意一个细节动态路由的404通配符路由必须最后添加否则首次刷新时容易跳进404页面。我们被这个坑过一次后来把所有静态路由的404定义改为动态注册后再添加问题消失。5.3 Vue打包后塞进SpringBoot的部署方式热搜词里有个问题很实在“vue打包放进springboot中”。我们的实践分两种情况开发环境/内部演示环境直接把Vue打包后的dist目录复制到某个SpringBoot服务通常是gateway或独立静态资源服务的src/main/resources/static下启动服务后通过网关访问。但这种方式有两个隐患一是前端资源跟后端包耦合更新前端要重新发布后端二是Vue Router使用history模式时刷新非首页会404。解决办法是后端加一个前转到index.html的Controller。SpringBoot里写一个简单的转发ControllerController public class SpaForwardController { RequestMapping(value {/login, /dashboard/**, /report/**}) public String forward() { return forward:/index.html; } }生产环境我强烈建议把Vue构建好的dist放到Nginx上由Nginx负责静态资源服务和history模式的try_files重写后端微服务只暴露网关地址前端通过网关跨域访问业务接口。用Nginx配置如下server { listen 80; server_name credit.example.com; location / { root /opt/credit-front/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://gateway-service:8888/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }改完以后前端和后端彻底解耦各自发版互不影响是运维幸福感提升最大的一步。5.4 评估进度可视化的SSE实现前面说了数据采集是异步并行的前端如果干等接口返回体验不行。我们做了一个评估进度页score-service在采集完成一步后通过SSEServer-Sent Events向前端推送进度。Vue端监听事件源更新进度条const eventSource new EventSource(/api/score-service/assess/progress?requestId requestId) eventSource.onmessage (event) { const progress JSON.parse(event.data) progressBar.value progress.percent progressText.value progress.message }SSE比WebSocket轻量又是标准HTTP协议前端接入成本很低非常适合这种单向推送场景。做的时候注意Nginx和网关都要关闭对SSE的缓冲否则进度推送会卡成一顿一顿的。6. 生产环境实测中的坑缓存穿透、锁失效、版本兼容与超时排查最后这部分是运行时最血泪的经验。很多问题不跑一段时间根本不可能暴露。我把印象最深的四个依次讲一遍。6.1 信用报告的缓存穿透信用评估场景有个特殊点单用户查询低频率但总量大比如用户一年查一次信用报告。这种数据如果用Redis缓存缓存命中率极低空缓存很多无效占用内存不做缓存又扛不住外部突发流量。更危险的是热点用户恶意刷单时后面全打到数据库上。我的解法是两级信用报告对象用对象存储存文件按报告ID生成文件名Redis只存索引和短期热点数据对于查不到数据的情况做空值缓存过期时间设短比如5分钟。同时前置一个布隆过滤器拦截明显不存在的请求挡住绝大多数的无效穿透。布隆过滤器在评估系统里的应用很妙把已收录用户的ID集合放入过滤器如果有人不断用随机ID触发查询布隆过滤器直接误判为“肯定不存在”流量根本到达不了数据库。注意布隆过滤器有误判率误判带来的后果只是“少数真实存在的用户被挡住”需要在查询时做二次确认别把好事变成误杀。6.2 Redisson锁在异常情况下的续期问题Redis分布式锁的经典坑是“业务执行时间超过锁有效期”。我们第一次用Redisson的tryLock(2, 30, TimeUnit.SECONDS)时遇到过批量评分任务跑了一个多小时的情况。虽然看门狗会自动续期但前提是调用的是不带leaseTime参数的lock()方法。一旦你手动指定了leaseTimeRedisson就认为你这个锁不需要看门狗续期到点了直接删锁此时如果业务没跑完其他实例就能抢到锁导致重复执行。排查过程花了一晚上反复看日志才发现两个实例同时在跑批量任务。定位到问题根源后把批量任务的锁改成lock()默认看门狗模式并设置强制全局最大执行时间兜底再没出现重复任务。6.3 SpringBoot版本太高引发的连带问题热搜词里那句“springboot版本太高”非常真实。我们前期搭环境时图新用了SpringBoot 3.2 SpringCloud 2023.0.1 SpringCloudAlibaba 2023.0.1结果第一坑是javax.servlet变成jakarta.servlet所有拦截器、Filter相关的旧写法全部编译报错。第二坑是SpringBoot 3是基于Jakarta EE 9的很多老的第三方组件不兼容比如旧版Druid连接池直接启动失败得切到druid-spring-boot-3-starter。第三个坑更隐蔽Spring Cloud LoadBalancer在2023版本里行为变化ribbon移除后想自定义负载均衡策略API和旧版完全不一样翻文档翻到怀疑人生。我的建议是除非新项目明确需要SpringBoot 3的GraalVM原生镜像或虚拟线程否则做微服务改造优先选SpringBoot 2.7 SpringCloud 2021.0.x SpringCloudAlibaba 2021.0.5.0这套稳定组合社区资料多坑基本被前人填平了。等生态完全成熟再升不迟。6.4 Feign默认超时引发的诡异超时最后讲一个排查最久的案例某天晚上评分接口P95从280ms涨到1.2s网关、下游服务、数据库全查了一遍没发现问题。后来把日志粒度调到DEBUG才发现score-service调用feature-service的Feign接口偶发等待10秒后超时。原因是Feign在SpringCloud 2021中默认连接超时10秒、读取超时60秒而我们的服务间调用正常情况下是几十毫秒这种慢调用被默认的超时容忍度掩盖了。解决方式很简单在配置中心统一全局服务间调用超时feign: client: config: default: connectTimeout: 1000 readTimeout: 5000但更关键的教训是服务间调用超时设置必须比数据库超时短、比外部数据源超时短否则一次外部慢调用会层层传导最后拖垮整个调用链。我们配合Sentinel做了Feign的降级Fallbackfeature-service抖动时score-service直接返回缓存特征不等待。还有一个小坑必须提OpenFeign默认懒加载第一次调用时初始化比较慢。我们踩过“上线后前5分钟偶发超时”的坑后来在配置里开启Feign的饥饿加载spring: cloud: openfeign: client: config: default: connectTimeout: 1000 readTimeout: 5000严格来说这个配置项在不同版本里位置不一样SpringCloud 2021后在spring.cloud.openfeign下早期版本在feign.client下。用的时候查一下自己版本的文档别照抄旧配置。结尾一点个人体会整套信用评估系统改造完最大的感受是微服务的难点从来不在写代码而在边界划分和默认配置的掌控。我们最终把分布式事务的数量压缩到了极少数靠的是“聚合写入优先 异步最终一致兜底”把线上事故数量降下来靠的是超时、线程池、缓存、锁这些细节一个不放过。如果你也在做类似的项目我建议不管架构多炫先把服务间调用的超时链路、幂等机制、分布式锁的选型这三件事想清楚再谈其他。这套信用评估系统上线到现在快一年不敢说架构最先进但至少每次策略调整都能当天上线每次外部数据源故障都不会拖垮全局核心评估接口的P95稳定在300毫秒以内这就值回改造的成本了。