ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

高校离校联办系统架构选型:从微服务到单表乐观锁的实战复盘

高校离校联办系统架构选型:从微服务到单表乐观锁的实战复盘 每年六月高校信息中心最紧张的不是迎新而是毕业生离校。图书馆要核对还书宿管要确认退宿财务要清查欠费教务处要回收毕业资格四部门各办各的学生一端一个入口反复刷新。这套离校联办功能我一开始按惯性上了微服务还规划了跨库同步结果评审阶段被生生砍掉最终换成了四部门标记置位加单表乐观锁。这篇就把整个评估过程和实际落地细节完整拆开讲清楚适合正在做校园业务系统、但又拿不准要不要上微服务架构的团队参考。1. 离校联办项目的业务复盘为什么第一版方案直奔微服务1.1 四部门联办的协作流程还原先还原业务。离校联办不是单一部门能闭环的系统学生毕业前要跑四个部门。每个部门查看自己的业务系统判断该学生是否“已经结清”或“已经完成”然后把这个结论同步到离校平台学生、辅导员、学院管理员在同一个界面上看到整体进度。四个部门的办理动作看起来彼此独立图书馆管图书归还宿管管宿舍验收财务管学费清欠教务处管毕业资格审查。但从学生视角看这就是一条流程状态空闲中、部分办理、全部完成。从数据视角看就是对同一条学生离校记录上的四个状态分别做原子更新。我当时第一版方案把四部门拆成四个独立服务想着边界清晰、互不干扰。每个服务独立部署、各自建库部门A的接口挂了不影响部门B。听起来很美但一旦涉及到状态汇总和学生端查询就不得不面对跨库数据汇总和同步的问题。1.2 初版方案微服务拆分加跨库同步初版架构图基本长这样应用网关做统一入口Nacos做注册中心和配置中心四个部门服务分别是library-service、dorm-service、finance-service、academic-service每个服务独立数据库。部门服务之间不直接调用通过消息队列广播办理结果再各自同步学生状态。接口文档用Knife4j聚合展示限流组件用Sentinel部署时再搞一套日志链路追踪。这种方案和很多微服务脚手架高度契合参考若依微服务Plus这类开源项目半天就能把骨架搭起来。架构图一画出来参会的人第一反应是“专业”但评审时细算之后发现这套架构解决的是“系统解耦”和“组织边界”的问题而离校联办真正需要的是“一条记录的四个状态在并发下保持正确”。1.3 评审时被追问到的三个问题评审会由架构组牵头主审人只问了三件事。第一件这套系统预计峰值并发是多少我说估算不高。他回了一句那为什么要用微服务第二件四个服务分开后状态汇总怎么做我说通过跨库同步。他追问同步延迟怎么办、同步失败怎么补偿、学生刷新看不到最新状态怎么解释第三件团队多少人我说核心开发两名。他笑了笑两个人维护服务注册中心、网关、限流组件、监控链路、四个服务实例加四个库出问题定位链路的时间比你写业务的时间还长。这三个问题当场把我问住了。我后来复盘问题的本质不是“要不要用微服务”而是“有没有认真评估真实流量和数据边界”。离校联办规模不大加各部门办理动作简单完全可以用一张单表加乐观锁解决微服务带来的复杂度远大于收益砍掉反而是最优解。2. 砍掉微服务与跨库同步评估的过程与依据2.1 第一步把并发模型先算清楚很多团队上微服务的理由只有一个“以后并发可能高”但这个“可能”没有量化。这件事值得先说透因为它是架构选型的根。按学校规模来算。本科生加研究生约一万人离校办理周期集中在三天有效办理高峰大概四个小时。每个毕业生平均需要查看离校状态五次办理动作按四算那总操作次数约一万乘以九也就是九万次除以四小时。换算成每秒并发平均大概是六次每秒。就算所有学生都挤在最后半小时操作峰值也就是五十次每秒。任何一个单库单表都能完美扛住。我在压测环境里用普通配置的MySQL单表单条主键查询每秒能到几千次带版本号的条件更新每秒也能处理几百次。离校联办实际流量连这个指标的十分之一都不到。所以第一版评估结论很直接微服务带来的横向扩容能力在这个场景里根本没有用武之地。当系统当前流量一秒钟只有几十次时先别谈服务拆分先把单表查询优化好。2.2 第二步跨库同步解决不了的一致性风险跨库同步是初版方案里最危险的一环。四个服务各自建库想要学生的离校状态完整就必须把四个库里的办理结果同步到一起或者在一处做聚合查询。可选方案无非三种。第一种用事务消息办理成功发MQ消费方收到后落库更新第二种用Canal监听binlog把数据同步到汇总库第三种用分布式事务框架比如Seata保证多库操作的原子性。这些方案在互联网业务里有大量实践但在离校联办这个小场景里每一种都是负担。MQ方案会引入消息丢失和重复消费问题学生显示界面上经常是“图书馆已办财务未办”而实际上财务已经办理完成只是同步延迟。Canal方案多了一个中间件部署和版本升级都要维护。分布式事务框架对团队要求更高事务协调器一旦抖动整个办理流程都会卡住。离校联办的业务本质是四部门对同一学生记录做状态修改本来应该在一个数据库事务里解决。用微服务强行把四个更新拆到四个库等于把一个确定性更新问题变成分布式一致性问题纯属自找麻烦。2.3 第三步评估团队与长期维护成本技术选型最容易被忽略的是长期维护成本。微服务不是框架加jar包就完事它背后是一整套设施服务注册中心、API网关、配置中心、限流降级、链路追踪、日志聚合、监控告警、部署流水线。我用一张表格对比单体加单库和微服务拆分的真实差异评估维度单体单库方案微服务加跨库方案部署复杂度一个应用一个库至少四个服务四个库加网关和注册中心接口联调成本本地方法直接调用服务间远程调用加超时重试数据一致性单库事务保证MQ、Canal或分布式事务补偿问题定位一个日志文件查完需梳理全链路TraceID团队人力要求两名熟练开发至少独立运维和架构角色故障范围单应用异常任一服务异常都可能拖垮依赖链表格一列完结论就清晰了。微服务适合业务边界明确、团队规模足够、流量需要动态扩展的成熟团队。离校联办是典型的小而稳的业务两个开发加一个DBA就能长期维护单体单库何必为展示微服务架构图而背负无限期运维负担。3. 四部门标记置位用一张表表达四个办理状态3.1 从需求到模型四个标记字段怎么设计砍掉微服务和跨库同步后核心数据模型回到一张表。我给它起了个通俗名字毕业生离校办理表。这张表保存学生基本信息加四个部门的办理结果。表结构大概如下CREATE TABLE graduate_leave_process ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(32) NOT NULL COMMENT 学号, student_name VARCHAR(64) NOT NULL COMMENT 姓名, college_code VARCHAR(32) NOT NULL COMMENT 学院编码, library_flag TINYINT NOT NULL DEFAULT 0 COMMENT 图书馆办理标记0未办理 1已办理, dorm_flag TINYINT NOT NULL DEFAULT 0 COMMENT 宿管办理标记0未办理 1已办理, finance_flag TINYINT NOT NULL DEFAULT 0 COMMENT 财务办理标记0未办理 1已办理, academic_flag TINYINT NOT NULL DEFAULT 0 COMMENT 教务处办理标记0未办理 1已办理, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_student_no (student_no), INDEX idx_college_code (college_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT毕业生离校办理表;这里说的“标记置位”就是把四个办理结果分别映射到四个TINYINT字段办理完成时把对应字段从0改成1。字段含义固定查询时一条SELECT就能读到全部状态显示层直接拼接进度。当时我也考虑过把四个开关合并到一个字段里用四个bit位比如TINYINT低四位分别代表四个部门。后来放弃的原因很简单可读性太差。运维排查时需要额外维护一份位映射表业务代码里全是位运算对团队不友好。四个TINYINT占的存储空间几乎可以忽略不计但SQL一眼就能看懂索引也能直接引用这才是长期好维护的设计。3.2 标记置位的原子更新与防重逻辑四部门是独立办理的两个部门可能在同一秒对同一个学生提交办理结果。如果不加控制后提交的部门就可能覆盖前一个部门的办理状态。用UPDATE加条件就能避免。比如财务处完成欠费核销后提交办理UPDATE graduate_leave_process SET finance_flag 1, version version 1 WHERE id #{id} AND finance_flag 0 AND version #{version};这里的两个条件很关键。finance_flag 0表示该部门状态还没被办理过避免重复置位也就是防重幂等。version条件保证提交时数据版本未被别人改动如果执行后影响行数为0说明要么财务已经办过要么版本号变了前端需要刷新数据重新判断。这类更新放在一个本地事务里哪怕四个部门同时提交不同字段InnoDB行锁会串行化对同一行记录的修改事务隔离级别默认的RC或RR都没有问题。比起跨库同步又来的一致性问题这里省心太多。3.3 状态回滚、查询映射与操作留痕虽然正常业务里四部门都朝“已办理”方向置位但真实场景经常有误操作。比如宿管点错了把不该退宿的学生标记成了已退宿这时候需要撤回。撤回操作要谨慎不能简单把标记从1改回0。我参照的规范是“只允许再次更新不直接物理删除”同时保留操作流水。我额外建了一张离校办理流水表记录每次变更的部门、操作人、变更前值、变更后值、时间、IP和备注。回滚时先查流水确认当前状态确由某次误操作产生再执行业务回滚更新并写入一条新流水。查询映射很简单。后端从graduate_leave_process表查出四个flag字段按位拼接成状态码int completedCount (libraryFlag dormFlag financeFlag academicFlag); String status completedCount 0 ? 未开始 : completedCount 4 ? 全部完成 : 办理中;这套逻辑最舒服的地方在于学生端高频查询的SQL永远只查一张表走主键或学号索引连接都不用压力全被单表性能吸收。4. 单表乐观锁并发更新记录的兜底机制4.1 为什么在当前场景乐观锁优于此悲观锁离校联办页面经常出现“先打开再提交”的操作。窗口人员看到某个学生信息后处理半天再点击“办理完成”这中间可能隔了几分钟甚至更久。如果这个过程用悲观锁也就是SELECT FOR UPDATE把记录锁住那在人工操作期间其他部门对该生的办理提交都会被阻塞最坏情况下造成等待超时。悲观锁适合短临界区和高冲突率的场景但这里的临界区从查询到提交包含大量人工时间不是一个理想的锁场景。乐观锁的思路是全程不锁数据库只在最后提交时做版本校验。两个部门同时读取同一版本先提交的能成功后提交的版本号对不上更新影响行数为0返回冲突提示。我们让冲突的一方刷新后再试一次即可。人工窗口的办理冲突概率本身很低乐观锁用极低成本解决了最后的并发写覆盖问题方案更优。4.2 版本号机制在表结构中的实现例子表里已经有version字段它就是为乐观锁服务的。每次UPDATE不仅设置标记位同时让version加一。这是标准思路版本号就是行级变更的单调递增序列既能做并发控制也能在事后排查问题时还原先后顺序。如果项目用MyBatis-Plus可以直接用Version注解让更新方法自动带上版本条件。我更倾向于核心更新语句自己写SQL因为离校办理的置位更新不只是改版本号还要求flag 0的防重条件。手写SQL能把业务规则和乐观锁放在同一个语句里语义更完整Update(UPDATE graduate_leave_process SET library_flag 1, version version 1 WHERE id #{id} AND version #{version} AND library_flag 0) int markLibraryDone(Param(id) Long id, Param(version) Long version);调用方处理冲突时做重试。我限定重试三次超过次数就报提示让人工介入for (int attempt 0; attempt 3; attempt) { LeaveProcess record leaveProcessMapper.selectById(id); int rows leaveProcessMapper.markLibraryDone(record.getId(), record.getVersion()); if (rows 1) { break; } // 更新失败说明版本冲突或已被办理短暂等待后重新读取 Thread.sleep(100L); }这里有个细节重试必须重新SELECT用最新的version覆盖本地旧值。否则重试循环里永远拿着过期版本只会死循环。4.3 乐观锁与标记置位如何协同工作现在把两件事组装起来看。标记置位解决“状态怎么存”乐观锁解决“并发更新怎么防错”二者配合在一条UPDATE语句里完成。比如图书馆和财务处在同一时刻处理同一个学生。图书馆拿到version5的记录财务也拿到version5的记录。图书馆先提交SQL执行成功version变成6。财务再提交时where条件里还是version5实际数据version已经是6匹配不到任何行影响行数为0前端提示“该生信息已发生变化请刷新”。刷新后财务看到最新状态和最新version再提交就能成功。整个过程没有锁等待没有跨库事务完全依赖数据库行锁和版本比对。这种设计对一个只有四个标记位的后台管理系统来说正确性已经完全够用。5. 实操落地改造过程中的关键步骤与踩坑记录5.1 从微服务骨架迁回单体单库的实用步骤方案确认后落地改造流程我按以下顺序执行。第一步合并代码工程。把四个部门的Spring Boot模块合并回同一个应用代码按包名隔离比如controller、service、mapper下分library、dorm、finance、academic四个package。这样保留业务边界但消除了远程调用。第二步合并数据库。把四套schema合并成一个schema原分库中的表全部迁移过来。表名保持原有前缀比如library_return_record、dorm_checkout_record避免冲突。第三步清理同步任务。删掉所有MQ生产者消费者、Canal订阅任务、分布式任务调度确认没有残留的跨库同步代码。第四步替换配置中心依赖。移除Nacos配置拉取改成本地Spring配置文件。Knife4j由多服务聚合改成单应用文档部署也精简成一个JVM进程加一个数据库实例。第五步全量对账。写脚本分别从旧的四个库和新的单库中统计办理结果数量比对四个部门的办理记录明细确保迁移过程中没有任何一条状态丢失。整个过程大概花了两个工作日。真正耗时的地方不是编码而是排查分散在各微服务里的隐含状态同步逻辑比如某个部门服务里有一段定时任务会反向更新另一张表的状态这种隐藏逻辑最容易被漏掉。5.2 实战中踩过的三个典型坑第一个坑是MyBatis二级缓存导致的版本号回退。业务层先SELECT记录再执行带version的UPDATE。开启二级缓存后同一个方法里连续两次查询缓存相同对象如果UPDATE返回后没有真正刷新缓存第二次重试拿到的是旧version导致无限冲突失败。解决方案对离校办理表强制关闭二级缓存或者重试前用selectById强制刷新一级缓存。第二个坑是UPDATE语句漏写version条件。有同事图省事认为只改一个flag字段不冲突就不需要版本条件。结果并发场景下出现了传说中的“互踩”两个部门几乎同时提交后提交的部门把前一个部门的更新覆盖了。最终回到正确方案版本条件不能省同时防重条件flag 0也必须保留。第三个坑是“撤回”操作误把乐观锁当成普通更新。撤回标记位时如果直接update版本号会出现“旧事务撤回成功但新数据已被覆盖”的情况。我后来规定撤回也必须走完整的事务和版本校验和正常置位一样严格。宁可多几次刷新提示也不能让数据悄悄丢状态。5.3 回归测试与压测结果改造完成后要做两轮测试。第一轮是功能回归分别模拟四部门独立办理、重复点击办理、并发提交同一学生、撤回后重新办理四类场景全部通过。第二轮是压测接口用压测工具打一百个并发持续五分钟单库CPU稳定在百分之二十以下版本冲突导致的更新失败率约为千分之三冲突后重试成功率达到百分之百。压测结果也验证了评估阶段的结论这个业务量级下单表单库完全够用。加了乐观锁后系统能在高并发极端情况下保证正确性对业务方而言已经是最好的体验。6. 什么时候才值得重新考虑微服务6.1 出现哪些信号时才考虑拆分不是说离校联办永远不需要微服务。如果将来出现下面这些信号可以重新讨论拆分学校规模扩大到几十万学生同时在线办理的峰值达到每秒上千次业务流程出现真正的多系统边界比如学生数据要在多个学院级平台之间独立开放或者团队扩大到每个业务域都有独立开发维护小组有专门的架构与运维角色。只有当服务的独立部署能带来实际的组织协同效率提升时微服务才有价值。否则“微服务架构图”再漂亮也只是给自己增加排障负担。6.2 即使拆分也不要先动数据边界如果未来真走到拆分那一步我的建议是优先做应用层拆分数据层谨慎动。业务仍然保留单库中的核心离校状态表只是不同部门服务通过RPC或API读写同一张表中间加一层独立的状态管理服务来保证原子性。真要到数据推库边界一般是因为出现完全独立的领域存储需求比如图书馆想自己沉淀借阅历史、财务想自己保留缴费对账。这种情况值得领域模型拆库但离校状态汇总还是应该保留在同一个事务边界内。6.3 给同类项目的评估清单我后来把这次评估沉淀成了一份清单适合所有中小型校园业务系统选型时使用。先量化真实流量再用数据说服自己而不是用架构趋势说服自己。跨库同步只要能避免就尽量避免单表事务比任何分布式方案都靠谱。状态类数据优先考虑标记字段加版本号不引入不必要的状态机中间件。评估长期维护成本时把人力、监控、排障链路全部算进去。留好扩展预案但扩展预案不等于初始方案就要做成大规模架构。这次改造给我最大的感受是做技术方案前先拿真实数据和业务场景把架构“约束”住。看到“微服务整合Knife4j、Nacos、Sentinel”之类的教程很激动但离校联办这样的系统更需要的是单表查询的清爽和乐观锁的稳当。把复杂留给真正复杂的地方把简单留给大多数场景这才是架构设计的长期主义。
返回列表