会议室预定系统微服务实战:SpringCloud+分布式锁+分布式事务 去年接了一个会议室预定系统的项目需求方把技术栈圈得很死SpringBoot Vue SpringCloud而且明确要求做成微服务分布式架构不能拿单体应用糊弄。心里第一反应是会议室预定这种业务也要上微服务但做完之后我必须承认这个系统太适合用来拆微服务了。它的核心业务只是选会议室、定时间段、提交预订可完整踩遍了微服务那套东西服务边界怎么划、注册中心怎么选、网关怎么收敛入口、并发抢同一间会议室怎么加分布式锁、跨服务的数据一致性怎么保证。这个项目的体量不大不小非常适合作为微服务入门到落地的完整样本。如果你正打算学SpringCloud或者想找一个能写到简历里的分布式项目会议室预定系统是一个很好的载体业务不复杂但技术点齐全。下面我把整个项目的拆解思路、选型理由、核心难点和踩过的坑完整写出来。1. 为什么一个会议室预定系统要拆成微服务1.1 需求拆开看这真不是一张表能搞定的很多人看到会议室预定这个名字第一反应是给张表选时间插入一条记录完事。实际做需求调研时就会发现系统要处理的是资源、时间、人、流程、消息五个维度叠在一起的事每个维度都不省心。会议室资源管理每间会议室有名称、位置、楼层、容量、设施标签投影、白板、视频终端、开放时段比如08:00-21:00还有预定提前量和最短时长限制。有些公司还会按级别区分会议室普通会议室随便订高管会议室和对外接待室必须走审批。预订流程管理用户按日期查看会议室占用日历选择空闲时间段填写议题、参会人数、投影需求提交后系统要做冲突校验。如果撞车了要明确提示哪段时间哪间会议室已被占用再顺手推荐附近的空闲会议室。审批链路高级会议室发起预订后生成审批任务审批人可以是部门主管或行政负责人。审批通过、驳回后要通知申请人还要更新会议室占用状态。审批超过N小时未处理要有超时提醒甚至自动释放。通知与提醒预订成功通知、审批结果通知、会前15分钟提醒、会议取消/变更通知。通知渠道一般不止一个站内信、邮件、企业IM都可能要接。统计与展示会议室使用率、热门时段、部门预订排行、爽约率。这个模块刚开始没人提一到月底行政就要报表没有就得临时写SQL从业务表里捞。把这些需求摆在一起会议室预定是典型的业务中台场景资源域会议室、流程域预订与审批、用户域账号权限、消息域通知。它们之间的耦合点只有预订单这一个核心对象非常适合按领域切开。上微服务不是技术表演而是业务复杂度到了一定程度之后天然会往这个方向走。1.2 单体也能跑但后面一定会卡壳这种系统用单体SpringBoot写初期开发速度确实快。但有两个现实问题会在项目中期开始发作。第一是团队协作。三四个人同时改一个工程预订模块加字段和审批模块改状态机代码冲突几乎不可避免。第二是数据模型互相侵蚀。单体架构下所有表都在一个库里预订服务要改状态的时候直接碰占用表审批服务也碰字段命名和维护归属很快会乱。更别说后面要接大屏、门牌终端、IM机器人时所有功能都堆在同一个进程里任何一个模块发布都要连累整套系统重新部署。我在这类项目上的经验是微服务的价值通常不在超高并发而在边界清晰。把会议室资源和审批流程分开后审批规则怎么改都不会动到会议室数据表业务流程之间的互相干扰会小很多。会议室预定系统虽然QPS不高但业务域边界非常清楚每个域有自己明显的数据归属和变化频率拆完之后反而比单体更好维护。1.3 什么样的项目才值得上微服务这里想多说一句因为会议室预定系统一直是被嘲讽杀鸡用牛刀的典型。我的判断标准很简单第一是否有清晰的业务域边界第二是否有跨域的数据流转第三是否有独立的扩展计划。三条都满足即使并发量不高也可以按微服务做。如果只是单人维护的毕业设计或内部小工具老老实实单体会比微服务省事十倍。但如果你是想拿这个项目当微服务技术栈的练手样本或者公司明确要沉淀一套可以复用的预订、审批基础设施那拆微服务就是合理的。很多人问SpringCloud入门有没有简洁路线我的答案是别去看那些虚构的电商项目就拿会议室预定这种身边就有的场景动手反而学得快因为你对业务有直觉遇到问题能判断对错。2. 服务拆分5个服务网关边界是怎么划出来的2.1 服务清单与职责最终我拆出来的结构是6个模块网关服务加5个业务服务。每个服务都有自己的数据库服务之间不能直接访问对方的表只能通过接口调用。服务名核心职责主要数据表gateway统一入口、路由转发、token校验、跨域无数据库user-service用户、部门、角色、登录鉴权、JWT签发sys_user, sys_role, sys_deptmeeting-room-service会议室资源、设施标签、开放时段room, room_facility, room_schedulebooking-service预订单创建、冲突校验、占用状态、签到booking, room_occupancyapproval-service审批任务、审批规则、审批历史approval_task, approval_rulenotify-service站内信、邮件/IM通知、重试队列notify_record当时也有人建议把统计模块单独拆一个analytics-service我算了算工作量把统计功能先放在booking-service里用定时任务跑汇总表。原因很简单一个服务初期没有必要拆得比业务域还细等报表需求复杂了再从booking里迁出去成本也不高。微服务常见的错误是拆得太碎拆出十几个服务每个服务就一两张表部署和联调成本反而把收益吃掉了。2.2 拆分依据三个标准缺一个我都会再合并回去这次拆分的标准有三条也是我后来做其他项目一直沿用的原则。第一条按业务域走不按功能页面走。会议室列表页看起来是前端一个菜单但它同时要查room-service和user-service不能因为页面整合就把服务合并。第二数据归属要清晰。每个服务拥有自己的数据库这个规则写进开发规范执行得很严格谁跨库查表就重写接口。第三变更频率隔离。审批规则和通知模板是变更最频繁的会议室基础数据几乎不变把它们放在不同服务里审批规则升级时不会影响会议室资源查询。后来验证下来第三条标准最有用。单体项目里改一个审批规则配置往往要把整个工程重新打包发布拆分后approval-service单独发布其他服务完全不受影响。会议室管理端半夜临时加一间会议室、改一段开放时间只动room-service不会因为审批服务正在发版就把资源查询也一起带挂。2.3 拆分后立刻遇到的两个新问题服务拆分带来的第一个问题是跨服务的数据引用。预订列表页需要显示会议室名称和预定人姓名但这些数据在room-service和user-service里booking-service只有ID。解决方案是冗余字段在创建预订单时把会议室名称、预定人姓名、部门名称一起冗余进booking表。查询时直接查本地表不再跨服务调接口。冗余的代价是数据可能不一致比如会议室改名这个概率很低且可以由管理端编辑后推送变更消息来更新冗余字段。第二个问题是跨服务的数据一致性。预订、审批、通知分别落在三个服务里本地事务肯定管不了整条链路。这个在第五章详细说是整套系统里最需要设计的地方也是面试时最能体现分布式功底的部分。3. SpringCloud组件选型与版本兼容性踩坑3.1 注册中心与配置中心Nacos为什么比Eureka更合适注册中心选了Nacos而不是Eureka这是有实际情况支撑的。Eureka 2.0在2020年后基本停止演进SpringCloud官方对Eureka的维护也逐步弱化。Nacos同时提供注册中心和配置中心配置修改后能实时推送到客户端这一点在微服务场景里太重要了。审批规则、通知模板、开关策略这些配置如果每次都要改代码重新发布微服务的优势就废了一半。选Nacos的过程中踩了一个典型的版本坑。当时开发机器上默认装了SpringBoot 2.7.5直接去集成Nacos客户端结果服务一直报连接超时反复排查才发现是SpringCloud Alibaba的版本对应关系没对上。这里整理了一份当时锁定的稳定版本组合后面所有服务都用的这一套组件版本备注SpringBoot2.6.13别用太新的2.7.x兼容性坑多SpringCloud2021.0.5与SpringBoot 2.6配套SpringCloud Alibaba2021.0.5.0这组版本号很容易记错Nacos Server2.2.3客户端与服务端保持相近大版本Nacos Client2.2.3由Alibaba依赖统一管理版本统一之后一切就顺利了。这里提醒一句微服务项目里能跑的版本组合比最新版本重要得多项目初始化时把版本矩阵锁死写进根pom的dependencyManagement里省下的都是排查时间。遇到网上教程给的代码跑不通十有八九是版本差异导致的而不是逻辑问题。3.2 网关Spring Cloud Gateway而不是Zuul网关选了Spring Cloud Gateway核心原因是它在Spring Cloud全家桶里的生态最好基于WebFlux实现网关本身非阻塞IO适合作为所有请求的流量入口。Zuul 1.x基于Servlet性能在网关这种高转发场景下不太够看。网关上的核心配置有这几块路由规则、token校验、跨域处理。下面是我实际用的路由配置片段spring: application: name: gateway cloud: gateway: routes: - id: user-route uri: lb://user-service predicates: - Path/api/user/** filters: - StripPrefix1 - id: room-route uri: lb://room-service predicates: - Path/api/room/** filters: - StripPrefix1 - id: booking-route uri: lb://booking-service predicates: - Path/api/booking/** filters: - StripPrefix1lb://前缀表示按服务名从注册中心做负载均衡前端只需要知道网关地址不需要感知背后有几个服务。前端baseURL统一配置成http://网关地址:9000/api/booking/**会被转发到booking-service排查问题的时候看请求路径就知道落在哪个服务上。网关里的GlobalFilter做了token校验白名单路径登录接口、静态资源直接放行其余请求从Header里取Authorization能解析出合法JWT就放行解析不到或过期就返回401。这个过滤器是全局的后面接入别的终端时规则可以复用。另外一定要提跨域。前端用Vite起在8080端口接口在9000浏览器跨域问题会直接拦截预检请求。我在网关里统一配置了CORS允许的来源可以写死前端地址也可以走配置中心动态下发允许的方法和Header要包含Authorization和Content-Type否则登录后请求还是会被浏览器拦截。3.3 服务间调用OpenFeign与Sentinel服务内部调用统一用OpenFeign。声明式HTTP客户端的好处是代码风格接近本地调用每个服务暴露的接口用FeignClient对上就行。比如booking-service要校验会议室占用状态时调用room-service的/api/room/checkAvailable在booking-service里定义一个Feign接口就能直接调用。OpenFeign的坑主要在超时上。默认连接超时和读超时都很短微服务之间调用涉及数据库查询、分布式锁获取一旦碰上慢查询就会抛超时异常。我后来在配置里显式放开feign: client: config: default: connectTimeout: 2000 readTimeout: 5000另外一个容易漏的是熔断。如果room-service出问题了booking-service如果一直等着Feign返回会把自身线程池打满。这里用Sentinel在provider侧做保护核心接口设置QPS阈值比如查询会议室占用这个接口单机阈值为200超过直接降级返回繁忙。预订和审批这种核心链路宁可短暂降级也不要让故障扩散到整个系统。4. 分布式锁守住同一会议室同一时段不重复预定4.1 为什么不靠数据库唯一索引会议室预定的核心约束是同一会议室在同一时间段只能有一个有效预订。单体项目里直接在房间、日期、开始时间、结束时间上建唯一索引配合数据库事务就能挡住大部分冲突。但微服务拆分后room-service管理资源booking-service管理预订单如果不做额外设计一次预订操作要跨两个服务完成先在room-service查占用再去booking-service插入预订单再回到room-service锁占用时段。这三步之间没有本地事务保护两个用户同时抢最后一小时空闲都是先查到空闲然后各自插入预订单最后都尝试锁占用很容易出现两单都创建成功的脏数据。所以光靠数据库约束不够必须在应用层加一把跨服务的锁。这就是分布式锁的用武之地。4.2 Redis分布式锁的正确姿势分布式锁我用的是Redis锁的key设计成业务维度的真实资源room:lock:105:2025-06-04:14:00-15:00。这个粒度既能精确锁定同一会议室同一时段又不会把不同会议室的预订互锁并发效率是最高的。加锁用Redis的SET命令带上三个参数String lockKey room:lock: roomId : date : startTime - endTime; String requestId UUID.randomUUID().toString(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);setIfAbsent对应的是SET key value NX PX 30000NX保证只有key不存在时才写入PX保证锁有自动过期时间。requestId存的是本次请求的唯一标识这个值非常关键后面释放锁要靠它。释放锁不能直接del因为存在一种危险情况线程A的锁快过期了线程B抢到了同一把锁A处理完业务后执行del把B的锁删了。为了避免这种误删释放锁必须做先验证再删除而且这个验证加删除必须是原子操作用Lua脚本实现if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end脚本先GET锁里的value和当前线程的requestId比对相等才DEL。不是自己的锁就返回0这样就杜绝了误删别人锁的问题。4.3 锁超时、续期与粒度选择加锁时设置了30秒过期时间但预订流程里要写数据库、调Feign、组装返回结果耗时很可能超过30秒。锁一旦过期并发请求就会再次进入临界区。核心问题是过期时间设多长才合适设长了服务宕机锁迟迟不释放设短了业务还没执行完锁就散了。正规的做法是给锁加自动续期也就是看门狗机制。用Redisson的RLock时默认会启动一个后台线程在锁过期前自动续期直到业务线程主动释放锁。如果不想引入Redisson也可以自己写一个续期线程每10秒执行一次续期业务结束后关闭线程。锁粒度也值得说。最初我把锁key设计成了整间会议室room:lock:105结果预订105会议室的所有时间段都被串行化了明明下午2点、3点是两个完全不相干的预订都要排队等待。改成会议室日期时间段后并发度上来了。这个粒度设计在分布式锁面试题里经常被追问核心回答思路是锁粒度要和业务资源粒度保持一致锁的东西越具体并发越高。4.4 压测验证锁的效果为了验证分布式锁到底靠不靠谱我用JMeter做了压测100个线程同时抢同一间会议室同一天下午14:00-15:00请求在booking-service入口并发提交。不加锁时100个请求几乎全部返回预订成功会议室占用表里出现几十条重叠记录。加锁之后同一时段最终只有1个请求成功其余返回该时段已被预订再叠加数据库唯一索引做兜底双保险下数据正确率100%。压测数据记录在第七章表格里。5. 分布式事务预定、审批、通知的最终一致性5.1 一个预订动作牵动三个服务的数据一次完整的预订动作逻辑上要做的事包括创建预订单booking-service、锁定会议室占用room-service、若走审批则创建审批任务approval-service、发出预订成功通知notify-service通常由MQ异步完成。这些数据分布在三个数据库里不可能用本地事务一次提交。如果不用分布式事务方案会出现很多怪现象预订单提示成功但占用没锁上两个用户都以为自己订到了审批通过了但预订单状态还停在待审批通知先发出去了审批后又被驳回用户收到预订成功后又收到申请被驳回体验很差。5.2 为什么选可靠消息最终一致性而不是Seata分布式事务的主流方案有Seata的AT/TCC模式、本地消息表可靠消息、MQ事务消息等。对会议室预定这个场景我排除了Seata。Seata AT模式能提供接近强一致的效果依赖全局锁和undo_log回滚日志代价是事务时间变长、锁冲突变多、运维要额外部署TC服务端。会议室预订对一致性的容忍度是最终一致就够通知晚到几秒没人介意审批结果晚同步几分钟也能接受。所以用可靠消息来保证业务数据的最终一致性价比高得多。5.3 本地消息表方案落地细节方案核心是业务数据和消息数据在同一个本地事务里写入然后由后台任务把消息可靠地发送到MQMQ消费者在目标服务里执行后续业务并幂等去重。具体流程拆成五步booking-service在创建预订单时同一个本地事务里写入booking表和outbox消息表。outbox记录的状态是0待发送消息体是整个预订事件JSON。一个定时任务每2秒扫描outbox表查status0且send_time小于当前时间的记录逐条发送到RabbitMQ的booking.exchange。RabbitMQ根据路由键分发给approval-service和notify-service消费。消费成功后notify-service写站内信并调用booking-service的接口把对应的outbox记录状态置为1已确认。如果MQ消息丢失或消费异常定时任务会在下轮继续重发消费方通过message_id唯一索引去重同一消息重复消费也不会产生两条通知。outbox表结构大致是这样CREATE TABLE outbox ( id bigint(20) NOT NULL AUTO_INCREMENT, message_id varchar(64) NOT NULL COMMENT 全局唯一消息ID, event_type varchar(32) NOT NULL COMMENT 事件类型BOOKING_CREATED / APPROVAL_RESULT, payload text NOT NULL COMMENT 业务消息体JSON, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待发送 1已确认, send_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_message_id (message_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;业务上还有一个反向链路approval-service审批通过后要把结果通知给booking-service去更新预订单状态。这条链路走同样的本地消息表到MQ到消费到确认流程只是事件类型变成了APPROVAL_RESULT。整套方案里没有强一致但任何一步失败都能靠定时任务重试找到根因不会留脏数据。5.4 兜底的对账任务可靠消息方案不是银弹极端情况下会出现写成功但MQ一直消费不了的消息积压。所以我还加了一个对账任务每天凌晨2点扫描状态为待审批超过24小时、或审批通过但预订单状态异常的数据生成异常清单发给管理员。对账任务里也用到了分布式锁防止多个实例同时跑对账导致重复处理。这两部分内容其实是在互相支撑分布式锁保证并发操作不越界分布式事务保证跨服务业务最终一致缺了任何一个会议室预定系统在分布式环境下都会出问题。6. Vue前端与微服务的协作网关、动态路由、实时状态6.1 前端技术栈与页面规划前端用的是Vue 3 Vite Element Plus Pinia Axios。Vite的开发体验比Webpack好太多热更新基本秒级。页面结构围绕业务场景拆成几个模块登录页、会议室列表页、预订日历页、我的预订页、审批中心页、会议室管理页、统计报表页。整个前端只有一套代码但不同角色登录后看到的菜单和操作按钮完全不同这套差异是用动态路由实现的。6.2 前端只面对网关一套接口前端axios实例的baseURL直接指向网关http://192.168.1.100:9000所有接口路径都带模块前缀由网关路由分发到对应微服务。这样做有几个好处前端不感知服务拆分后端服务拆分变化不影响前端登录接口走/api/user/login用户信息接口走/api/user/info路径即模块排障时看网络面板就能知道请求落在哪个服务。axios拦截器里统一做了两件事请求拦截器把本地存的token加到Authorization头响应拦截器对401做统一跳转登录处理对业务错误码弹出Message提示。这样每个页面都不需要重复写鉴权和错误处理代码。如果不想单独部署nginx也可以把前端npm run build之后的dist静态资源直接放进网关或某一个SpringBoot服务的static目录里统一发布适合内网小规模场景。但正规环境还是建议前端独立部署用nginx托管静态文件再反向代理到网关这样前端静态资源和后端服务互不影响。6.3 登录鉴权与动态路由登录流程用户提交用户名密码user-service验证后返回JWT前端把token存到localStorage再调/api/user/info拿到用户信息、角色和菜单权限列表。动态路由是关键点前端不能把所有菜单都写死在静态路由表里。我用后端返回的菜单标识数组由前端把已注册的异步组件映射到路由配置然后通过router.addRoute动态挂载。一个非常常见的坑是刷新白屏刷新页面时router初始化发生在菜单接口返回之前动态路由还没来得及挂载匹配不到页面就白屏了。解决方法是把获取用户信息挂载动态路由放到main.js初始流程的前置await里确保刷新后路由表先就绪再渲染页面。权限粒度上按钮级权限用自定义指令v-permission去控制。会议室管理页面的删除导出按钮只有管理员角色可见普通用户只显示预订取消按钮。这样权限不是只停留在菜单隐藏层面而是真正控制到每个操作入口。6.4 日历视图与实时状态推送会议室预定的核心交互都在日历页。我用了FullCalendar做日、周、月视图把一天切成会议室的时间轴灰色块表示已占用绿色块表示空闲点击空闲时间段弹出预订表单。时间段选择器做了简单的步进对齐只允许整点或半点开始避免用户提交任意时间导致后续冲突校验复杂化。前端做的时段冲突校验只是体验优化真正的保护在后端的分布式锁。这一点必须跟用户和团队成员明确前端校验可以挡掉80%的常规误操作剩下20%的并发碰撞必须由后端兜底。实时状态更新用WebSocket实现。会议室的占用状态变化、审批结果、通知未读数都会通过WebSocket推送到前端页面不需要手动刷新就能看到最新结果。链路是这样的后端服务处理完业务后通过MQ通知WebSocket服务端点再由WebSocket把事件推到对应在线用户。这里和第五章的分布式事务消息是同一条链路最终一致性完成后状态自然就推过来了。7. 压测数据、部署方式与几个值得记住的教训7.1 部署架构与资源规划整套系统用Docker Compose编排部署mysql、redis、rabbitmq、nacos-server各一个容器5个业务服务各自一个容器前端用nginx容器托管静态文件并反代网关。网关是唯一暴露给外部的端口前端nginx也只配置网关地址作为上游。这种部署方式的好处是开发和联调环境完全一致本地起不来整栈的时候直接docker compose up就能复现。服务实例数量按压力情况调节初期每个服务1个实例压测后发现booking-service和gateway是热点各扩到2个实例room-service和notify-service保持1个也够。微服务部署后有一个容易被忽略的资源问题每个JVM默认堆内存可能很大5个服务全部启动整台服务器16G内存基本被占掉大半。我后来统一给每个服务加了-Xms256m -Xmx512m参数QPS不降但内存占用大幅下降一台4核8G的测试机就能跑完整套环境。7.2 JMeter压测结果压测主要针对两个核心场景并发创建预订、同一时段抢订。并发抢订的压测结果记录如下场景并发数无锁方案加锁后同一时段抢订100线程约93个请求返回成功占用表大量重叠仅1个成功其余返回已占用整体预订接口200QPS持续5分钟平均RT约220ms平均RT约280ms错误率0.2%加锁后整体RT上升了约60ms原因是每次预订要先获取分布式锁、再查占用、再写库、再释放锁。对会议室预定这种低频但强一致要求的场景这60ms完全可接受换来的是数据正确率100%。7.3 踩坑记录踩坑一SpringBoot版本太高导致的Nacos连接异常。项目启动时用了SpringBoot 2.7.xNacos客户端始终报ConnectTimeout折腾好几个小时。版本矩阵表锁死之后问题直接消失。微服务项目第一件事就是锁版本矩阵。踩坑二释放锁时误删别人的锁。早期实现直接用redisTemplate.delete(lockKey)压测时出现了A请求释放B请求锁的偶发现象后来改成Lua脚本先比对requestId再删除问题彻底解决。踩坑三OpenFeign调用超时。booking-service调用room-service的占用校验接口默认超时太短测试环境一有慢SQL就抛超时最后不仅调长了超时时间还在room-service侧加了索引优化双管齐下才稳定。踩坑四通知消息重复发送。本地消息表方案上线后的前两天用户反映偶尔收到两条预订成功通知。排查发现是消费成功后回调标记outbox状态的接口超时定时任务重发了消息。给notify-service的消息消费加上message_id去重站内信表建唯一索引后重复通知消失。踩坑五Vue动态路由刷新白屏。这个问题前面已经说过本质是动态挂载路由的时机问题把用户信息获取前置到应用初始化流程里就解决了。7.4 一点收尾的个人体会做完这个项目我对微服务架构的理解比看十篇博客都深。会议室预定系统没有任何高并发的炫技点但它天然跨了好几个业务域逼着我去思考服务边界、分布式锁、分布式事务、消息可靠性这些问题。如果你也想系统练一遍SpringBootVueSpringCloud这套技术栈会议室预定系统是个很合适的载体既不会因为业务太复杂学不动又覆盖了微服务里最常被问到的核心难点。最后再分享一个实用技巧做这种分布式系统边开发边把关键链路画成时序图存在项目文档里。排查线上问题的时候对照时序图看日志会直观很多比自己现场回忆要可靠得多。