设备科的微信群从早上七点就开始响:ICU的呼吸机报错误,3号楼的注射泵需要校准,检验科的离心机又转不动了。在这套系统之前,整个过程就是临床科室打电话报修、设备科手写工单、中午才能把单子送到工程师手里,修完再补一张纸质记录。备件库的账永远对不上,哪些设备到了校准周期全靠工程师脑子记。这些混乱叠加在一起,就是我要做基于SpringBoot的医疗设备维护平台的根本原因。
这篇文章不是教科书式的需求分析,而是我从零设计、开发到上线这套系统的完整记录。内容涵盖数据模型设计、工单全流程流转、定时保养任务、权限与操作审计、Vue前端整合SpringBoot的部署方式,最后还有一批真实踩坑记录。如果你正好在用SpringBoot做业务管理系统,或者打算进入医疗信息化这个方向,这篇文章能让你少走不少弯路。
1. 医疗设备维护的痛点与SpringBoot的适配性分析
1.1 设备科日常:救火式管理下被忽视的三件事
我调研设备科工作流程的时候发现一个规律:大家忙到飞起,但问题永远在原地打转。大部分精力都花在"救火"上——设备坏了赶紧修,修完就完了,没人关心这台设备这个月坏了几次、平均修复时长是多少、哪个品牌故障率偏高。这种模式长期运行下去,会出现三个容易被忽视的缺口:
第一是设备档案不成体系。医疗设备从采购到报废要经历十几年,中间有安装记录、校准记录、维修记录、配件更换记录。纸质单据一旦丢失,等于这台设备的履历断档。第二是报修工单不闭环。临床科室报修后,维修结果有没有反馈、科室认不认可、备件用了什么,完全靠工程师自觉。第三是预防性维护缺失。很多设备故障其实是可以靠定期保养避免的,但保养计划往往停留在Excel表格里,过期了也没人提醒。
做一个维护平台,本质上是把这三块补上:让每一台设备有完整档案,让每一次报修有闭环记录,让保养计划自动触发并留痕。这个认知决定了系统最核心的三个模块——设备管理、工单管理、保养计划,而不是一上来就堆砌花哨功能。
1.2 技术选型:为什么SpringBoot是我最后的决定
我见过不少同行一上手就想上微服务、用Kafka、搞分布式事务。但医疗设备维护平台的实际场景是医院内部部署,用户量是几百人,设备量是几千台,数据量在百万级别。这个规模下,微服务的复杂度会直接拖垮维护效率,反而单体应用最合适。
选SpringBoot有几个明确理由。第一,生态成熟。认证授权用Spring Security,ORM用MyBatis-Plus,定时任务用Spring Task,消息推送用WebSocket或者对接钉钉/企业微信机器人,这些组件都有完整文档,出了问题也容易搜到答案。第二,部署简单。打出一个可执行jar包就能跑,医院内网服务器条件普遍一般,一个jar包加一个MySQL实例,是最省心的组合。第三,社区活跃度够高。基于SpringBoot的开源后台管理系统模板很多,虽然我最终没有直接套模板,但参考价值很大。
技术选型这事,我的原则是"够用就好,不追新"。医疗行业对系统稳定性极其敏感,医院信息科更看重可控可维护,而不是技术多新潮。SpringBoot 2.7.x配JDK8,在这个场景下比SpringBoot 3.x配JDK17更稳妥,具体原因后面踩坑部分细说。
1.3 可扩展的工程结构:模块分包与统一响应体
项目结构我采用了"按业务模块分包"的方式,而不是经典的"按技术层分包"。一个设备模块里同时放Controller、Service、Mapper,改动设备相关功能时只需要进入这一个包,不用频繁切换目录。
com.hospital.emms ├── common // 统一返回体、异常、常量、工具类 ├── config // 全局配置类 ├── controller // 接口层 ├── service // 业务层 ├── mapper // MyBatis-Plus数据访问层 ├── entity // 数据库实体 ├── dto // 入参校验对象 ├── vo // 出参封装对象 └── module ├── equipment // 设备档案模块 ├── workorder // 工单模块 ├── maintenance // 保养计划模块 ├── parts // 备件管理模块 ├── user // 用户权限模块 └── report // 统计报表模块所有接口返回统一使用R 对象,包含code、message和data三个字段。前端请求拦截器里只要判断code是否为200就能决定是否弹出错误信息,省去一大堆重复判断。
2. 核心数据模型:让设备、工单、保养计划互相咬合
2.1 设备档案表:固定资产编码是唯一主键
设备档案是整个平台的数据基石。医疗设备有个特点:固定资产编号和设备序列号是两套体系。设备序列号是厂家出厂时的全球唯一标识,而固定资产编号是医院资产管理部门自己编的,用于财务入账和实物盘点。系统设计时必须用固定资产编号作为主键维度,因为医院内部所有流程——领用、报修、转移、报废——都以这个编号为准。
CREATE TABLE device_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_code VARCHAR(32) NOT NULL COMMENT '固定资产编号', device_name VARCHAR(128) NOT NULL COMMENT '设备名称', model VARCHAR(64) COMMENT '型号', device_type VARCHAR(32) COMMENT '设备分类', department_id BIGINT COMMENT '所在科室', supplier VARCHAR(128) COMMENT '供应商', install_date DATE COMMENT '安装日期', warranty_end DATE COMMENT '保修截止日期', status TINYINT COMMENT '0-停机 1-正常 2-维修中 3-报废', create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_device_code (device_code) ) COMMENT='设备档案表';这里面有两点要提醒。一是device_type不要直接用字符串,建议做一张设备分类字典表,因为医院设备分类(如"生命体征监测设备""医学影像设备""急救设备")是树形结构,用字典表方便后续按分类统计故障率。二是status字段务必用数字枚举,不要用字符串,否则后续写统计SQL时会非常痛苦。
2.2 工单状态机:一次报修要经历什么
工单是平台的流转核心,状态不能随意跳转,必须要有一个状态机来约束。我把工单定义为六个状态:待派单、待处理、处理中、待验收、已完成、已关闭。每个状态之间的跳转有明确的操作条件和角色要求,见下表。
| 状态 | 操作人 | 动作 | 触发条件 |
|---|---|---|---|
| 待派单 | 设备科管理员 | 受理报修 | 用户提单成功 |
| 待派单 | 设备科管理员 | 转外修 | 内部无法处理 |
| 待处理 | 维修工程师 | 接单 | 管理员派单或工程师抢单 |
| 处理中 | 维修工程师 | 开始维修 | 工程师到场操作 |
| 待验收 | 报修科室/管理员 | 验收 | 工程师提交维修结果 |
| 已完成 | 系统 | 关闭工单 | 验收通过 |
| 已关闭 | 设备科管理员 | 关闭 | 报修取消或设备报废 |
状态机的好处在于:第一,避免工程师跳过"处理中"直接提交完成,保证流程可溯源;第二,系统可以基于状态自动计算每个环节的耗时,比如"待派单阶段平均耗时"和"响应时效",这些指标是设备科月度考核的重要依据。
2.3 保养计划与备件台账:防止"该保养没保养、该换件没换件"
保养计划表是预防性维护的数据源,记录了每台设备的保养周期和上次保养时间。关键字段包括设备ID、保养类型(日保/周保/月保/季保/年保)、上次保养日期、下次保养日期、负责人。系统每天定时扫描"下次保养日期小于等于今天"的记录,自动生成保养工单。
备件管理表分为两层:备件主表维护备件编码、名称、规格和供应商;备件库存表维护当前库存和安全库存阈值。这两个表分开设计的原因是同一备件可能放在多个库房,主表在业务上更清晰。
这里的核心规则是:备件出库必须关联工单ID。维修工单提交时如果涉及更换备件,系统自动扣减库存并生成出库记录,这样"哪台设备在什么时候换过什么配件"就完全可追溯。这个设计在实际运维中非常重要,因为医疗设备检测和审计时经常需要这类数据。
3. 工单全流程实现:从提单到验收的事务与并发控制
3.1 重复提单的防重逻辑
实际运行中第一个遇到的问题就是重复提单。临床科室的设备管理员在系统里填完报修单后,因为网络慢或者操作不熟练,经常会点两次提交按钮。如果没有防重逻辑,一个故障会生成两张甚至三张工单,设备科就得花时间合并,挺闹心的。
我的处理方案是双重防重。前端在提交按钮上做loading禁用,这个不细说。后端在Service层做时间段校验:同一台设备在五分钟内如果已存在待处理状态的工单,直接拒绝再次提交。
@Override @Transactional(rollbackFor = Exception.class) public Long createWorkOrder(WorkOrderCreateDTO dto) { DeviceInfo device = deviceMapper.selectById(dto.getDeviceId()); if (device == null || device.getStatus() == DEVICE_STATUS_SCRAPPED) { throw new BizException("设备不存在或已报废"); } LocalDateTime now = LocalDateTime.now(); Integer count = workOrderMapper.countByDeviceAndTime( dto.getDeviceId(), now.minusMinutes(5), WorkOrderStatus.PENDING_DISPATCH.getValue()); if (count > 0) { throw new BizException("该设备近期已存在待处理工单,请勿重复提交"); } WorkOrder order = new WorkOrder(); BeanUtils.copyProperties(dto, order); order.setOrderNo(generateOrderNo()); order.setStatus(WorkOrderStatus.PENDING_DISPATCH.getValue()); workOrderMapper.insert(order); return order.getId(); }这个方案没有用分布式锁,因为单机部署下时间窗口校验已经足够。但要注意这里的数据库查询必须走"设备ID+状态"组合索引,否则工单量涨上去之后查询会变慢。
3.2 派单策略:自动匹配技能与负载均衡
派单功能我做了两种模式并行。第一种是管理员手动派单,直接在工单详情页选择工程师。第二种是自动派单,系统根据两个维度推荐最合适的工程师:一是工程师的技能标签,比如某工程师擅长呼吸机、麻醉机,设备类型匹配度高的优先;二是工程师当前待处理工单数量,排单越少的越优先,兼顾负载均衡。
自动派单的具体实现并不复杂,本质上是一次带排序条件的查询。在维护工程师表时增加一行skill_tags字段,用逗号分隔的字符串存储技能标签,然后使用MySQL的FIND_IN_SET函数做匹配筛选,加上当前待处理数量字段做二次排序。
public List<EngineerWorkloadVO> recommendEngineers(Long deviceId) { DeviceInfo device = deviceMapper.selectById(deviceId); return engineerMapper.selectEngineerByDeviceType( device.getDeviceType(), WorkOrderStatus.PENDING.getValue()); }我实际运行后认为必须保留手动派单。自动派单只是减少管理员的重复工作,但医院场景总是有特殊情况:某工程师今天排休、某科室指定要某个老师傅、有些外包维修必须指派给供应商。这些特殊规则永远存在,自动派单做不了,系统必须有兜底。
3.3 备件出库的行锁细节
备件出库是最容易出现并发问题的地方。两个工程师同时提交工单,都需要从备件库领取同一个型号的配件,这时候如果只是先查库存再更新库存,大概率会出现超扣现象。库存为负对医疗设备维护来说是不可接受的,因为备件台账要和财务对账。
解决方案很简单:使用MySQL的SELECT ... FOR UPDATE行锁,出库和生成出库记录放在同一个事务里。
@Transactional(rollbackFor = Exception.class) public void outbound(PartOutboundDTO dto) { PartStock stock = partStockMapper.selectByPartIdForUpdate(dto.getPartId()); if (stock.getStock() < dto.getCount()) { throw new BizException("备件库存不足"); } stock.setStock(stock.getStock() - dto.getCount()); partStockMapper.updateById(stock); PartRecord record = new PartRecord(); record.setPartId(dto.getPartId()); record.setWorkOrderId(dto.getWorkOrderId()); record.setCount(dto.getCount()); record.setType(PartRecordType.OUT); partRecordMapper.insert(record); }必须提醒的是:行锁只有放在事务里才生效。如果你在方法上漏掉@Transactional注解,锁会在SQL执行完立即释放,并发问题依然存在。另外,selectByPartIdForUpdate这个方法不能走MyBatis-Plus的通用查询,必须在Mapper XML里手写SELECT * FROM part_stock WHERE part_id = #{partId} FOR UPDATE。
3.4 验收闭环与维修记录生成
工程师维修完成后,需要填写处理内容、更换备件明细、维修结论(已修复/待观察/无法修复需要外修),然后提交进入待验收状态。此时系统会给报修科室发送通知,科室确认验收后工单才能真正关闭。
验收环节我增加了一个强制性:科室验收时必须在评价中勾选"设备运行是否正常",这个看似简单的设计其实很有用。一是让报修科室有参与感,二是后续统计"维修后复发率"时可以直接通过这个评价字段筛数据。如果只做流程闭环而忽略评价数据,系统的分析价值会打很大折扣。
工单关闭后,系统会自动向设备档案表追加一条维修记录。这个记录不需要单独建表,直接写在工单表里即可,需要查看设备履历时按device_id查工单列表就行。这里要注意查询时最好带上状态条件,避免把"待派单、已关闭"这些没有实际维修动作的记录也展示出来。
4. 自动保养提醒:定时任务与消息通知的工程化
4.1 @Scheduled的默认坑:单线程与多实例重复执行
Spring Task的@Scheduled注解用起来很简单,但深入使用就会发现两个隐蔽的坑。
第一个坑是默认单线程执行。Spring的定时任务默认使用单线程调度器,如果定义了多个@Scheduled任务,它们会排队执行。某个任务阻塞了,后续任务全部被卡住。我实际遇到一次:凌晨的保养任务生成逻辑里调用了一个外部接口,接口超时了30秒,结果当天上午的数据统计定时任务延迟执行,后台数据滞后了整整一个上午。
解决办法是显式配置调度线程池。
@Configuration public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5)); } }第二个坑是多实例部署重复执行。如果系统部署了两台实例,所有@Scheduled任务会在两台机器上同时执行,结果就是同一批保养工单生成两遍、通知短信发两遍。这个问题的解决方案放到第七章踩坑记录里详细说,因为我是上线之后才踩到的。
4.2 保养任务生成:幂等与去重
保养工单的生成逻辑核心是扫描maintenance_plan表里"下次保养日期小于等于今天"的过期计划,然后为每个计划生成一张保养工单。但这里有一个前提:同一条保养计划不能被重复转换成工单。
我的做法是去重判断加状态转换两步走。查询保养计划时,只查状态为"正常"的计划,生成工单后立刻把计划状态改成"已生成待执行",这样即使同一个计划被并发扫描到两次,第二次查询也查不到这条记录。
@Component public class MaintenanceScheduler { @Scheduled(cron = "0 30 2 * * ?") public void generateMaintenanceOrders() { List<MaintenancePlan> plans = maintenancePlanMapper.selectExpiredPlans(LocalDate.now()); for (MaintenancePlan plan : plans) { Boolean updated = maintenancePlanMapper.compareAndSetStatus( plan.getId(), MaintenancePlanStatus.NORMAL.getValue(), MaintenancePlanStatus.GENERATED.getValue()); if (!updated) { // 另一线程已处理,跳过 continue; } createMaintenanceOrder(plan); } } }这里用了一个compareAndSetStatus操作,本质上是数据库中乐观锁的变种。UPDATE语句里带上WHERE status = NORMAL条件,如果更新影响行数为0,说明这条记录已被其他线程处理过,直接跳过。这样即使定时任务被手动触发两次,也不会产生重复保养工单。
4.3 消息通知渠道的取舍
系统上线初期,我同时做了站内信和短信通知。结果发现短信费用太高,而且用于内部系统有点浪费。后来调整成:站内信保留,但把通知优先级改为"钉钉群机器人Webhook"为主。设备科和工程师基本都在钉钉工作群里,机器人推送一条"您有一张新的待处理工单,编号W20250612001"的信息,比短信更及时,而且完全不花钱。
技术实现其实很简单,就是调用钉钉开放平台的Webhook地址,POST一个JSON。SpringBoot里封装一个DingTalkNotifier组件,全局共用。
@Component public class DingTalkNotifier { @Value("${dingtalk.webhook}") private String webhook; public void sendMaintenanceRemind(String content) { Map<String, Object> body = new HashMap<>(); body.put("msgtype", "text"); Map<String, String> text = new HashMap<>(); text.put("content", content); body.put("text", text); restTemplate.postForEntity(webhook, body, String.class); } }这里要提醒的是,Webhook机器人有频率限制,高峰时段一次推送太多消息会被限流。所以批量生成保养工单时,不要每张工单单独推一条,而是汇总成一两条摘要消息推送。比如"今日共生成保养工单15张,其中危急值设备2台,请注意处理"。
5. 权限控制与操作留痕:医疗合规要求的落地
5.1 RBAC角色权限设计
医疗设备维护平台虽然用户量不大,但权限模型绝不能省。因为不同角色的操作边界非常清晰:临床科室只能提单和验收,工程师只能处理分配给自己的工单,设备科管理员拥有全部管理权限,院领导可以看到所有统计报表。
我实现了标准的RBAC模型,五张核心表:用户表、角色表、菜单权限表、用户角色关联表、角色菜单关联表。基于Spring Security的@PreAuthorize注解做方法级权限控制,比如只有拥有"workorder:assign"权限的角色才能调用派单接口。
角色划分这里有一个经验:不要把权限粒度设计得太细。我最初设计了增删改查每个接口对应一个权限标识,结果配置起来非常繁琐,管理员配置角色时也一头雾水。后来简化为:设备管理、工单管理、保养计划、备件管理、统计报表、系统设置六大模块,每个模块对应查看和操作两种权限,配置成本降了一半,实际使用完全够用。
5.2 Spring Security集成思路
集成Spring Security时,我没有直接用默认的登录方式和Session机制,而是选择了JWT无状态认证,配合Spring Security的过滤器链。后端提供/login接口,校验账号密码后签发JWT令牌,前端在请求头里携带Authorization: Bearer 。
核心配置类里需要重写三个关键部分:configure(HttpSecurity http)定义哪些接口放行、哪些需要认证;configure(AuthenticationManagerBuilder auth)配置自定义UserDetailsService查询用户;增加JwtAuthenticationFilter作为认证过滤器。
这里要说一个坑:JWT的密钥和过期时间必须放在配置文件里,不要硬编码。我项目迭代过程中改过一次密钥,当时为了省事写死在Filter里,结果改配置还要重新编译打包。另外,JWT过期后用户会被强制退出,这个提示信息要友好,前端在收到401时自动跳转登录页。
5.3 AOP操作日志与设备变更记录
操作日志是医疗合规要求的硬性指标。设备科复查一台设备半年内的维修履历时,不能只看工单,还要知道这台设备的基础信息什么时候被改过、谁改的、改成了什么。这个需求用AOP切面实现最合理。
@Aspect @Component public class OperationLogAspect { @Around("@annotation(operationLog)") public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long startTime = System.currentTimeMillis(); Object result; try { result = joinPoint.proceed(); } catch (Exception e) { saveLog(joinPoint, operationLog, "失败", e.getMessage()); throw e; } saveLog(joinPoint, operationLog, "成功", "耗时" + (System.currentTimeMillis() - startTime) + "ms"); return result; } }这个切面的核心价值不在于记录"某人访问了某个接口",而在于配合后台"操作日志查询"页面,让设备科主任可以在五分钟内查清任何一次设备状态变更的前因后果。操作日志表不需要存太多冗余字段,操作人、操作模块、操作类型、请求参数、执行结果、耗费时间,六个字段足够。
设备关键信息的变更记录,我单独做了一张device_log表,通过MyBatis-Plus的字段自动填充功能记录创建人和创建时间,再在Service层统一写入变更日志。这样设备档案的每一次修改都留痕,不会出现"设备状态莫名从正常变成维修中"却找不到操作记录的情况。
6. Vue前端打包放进SpringBoot的部署实践
6.1 为什么选择前后端合并部署
很多项目团队采用前后端完全分离:前端Vue项目部署在Nginx,后端SpringBoot单独部署。但我这次选择了一个更保守的方案——前端构建产物直接放进SpringBoot的static目录,整个系统最终只有一个可执行jar包。
原因很简单,医院内网的运维环境有限。很多医院机房没有标准的Nginx环境,信息科人员对Linux操作也不够熟练,我如果交付一个需要部署两个节点的系统,后续排除故障时所有排查动作都要加倍。合并部署后,信息科只需要执行java -jar emms.jar就能启动系统,数据升级、服务重启都只需操作一个进程,运维负担小非常多。
6.2 dist目录集成与history路由刷新404处理
前端Vue项目开发完成后,执行npm run build生成dist目录,把dist里的所有文件复制到SpringBoot的src/main/resources/static目录下。默认情况下,SpringBoot会把static目录映射为根路径/,访问http://ip:8080/时自动返回index.html。
但是前端路由如果用了history模式(即URL里没有#号),刷新页面时会直接请求后端接口路径,比如刷新http://ip:8080/device/list,SpringBoot找不到对应的Controller,就会返回404。解决这个问题需要在后端做一个SpaForwardController,把前端路由下的非API路径统一forward到index.html。
@Controller public class SpaForwardController { @GetMapping(value = {"/", "/{path:[^\\.]*}", "/**/{path:[^\\.]*}"}) public String forward() { return "forward:/index.html"; } }这个方案的关键在于正则表达式[^\\.]*,意思是路径中不包含点号。这样既能匹配前端路由,又不会拦截静态资源文件(CSS、JS都带点号)和带点号的API路径。我最初没加这个判断,结果把所有静态资源请求全部转发了,页面样式全部丢失,调试了好一阵子才发现是这个正则没写对。
6.3 内网部署的依赖与版本对齐
前端整合SpringBoot部署还有一个容易被忽略的问题:接口请求路径要统一加/api前缀,并在SpringBoot配置一个server.servlet.context-path=/api,否则前端请求和后端Controller路径可能产生冲突。
另一个实际工作中遇到的问题是关于前端构建配置的。Vue项目使用Vite构建时,需要把base设置为'./',如果保持默认的绝对路径,打包后的资源引用可能会出现找不到静态资源的错误。这个配置在vite.config.js里:
export default defineConfig({ base: './', // 其他配置 })采用相对路径之后,即使把系统部署到某个子路径下(比如http://ip:8080/emms/),前端资源也能正常加载。
7. 上线后的踩坑记录与调优建议
7.1 SpringBoot版本别追新:JDK版本是硬约束
我最初用的是SpringBoot 3.2 + JDK17,开发阶段一切正常,但到了部署阶段发现医院内网服务器装的是JDK8。SpringBoot 3.x最低要求JDK17,这意味着要么让信息科升级JDK,要么我回退版本重写一遍代码。考虑到医院系统软件迭代的谨慎性,让信息科升JDK并不现实,最终只能回退到SpringBoot 2.7.18 + JDK8,把代码里用到的新语法全部改回旧版。
这是我的第一个教训:技术选型的第一步不是看框架有多新,而是确认目标环境的JDK版本。医疗行业的老旧服务器远比想象中多,JDK8仍然是最保守可靠的选择。慎重起见,我建议如果目标环境未知,优先选择SpringBoot 2.7.x,这是最后一个兼容JDK8的大版本,而且还在社区支持周期内。
7.2 MyBatis-Plus分页插件与自定义SQL的冲突
统计报表模块上线初期,我发现分页查询的total数量经常错误的翻倍,排查定位后发现是MyBatis-Plus分页插件和自定义JOIN查询冲突了。MyBatis-Plus的Page分页插件自动在SQL尾部拼接LIMIT语句,但如果使用自定义SQL且包含JOIN操作,插件在统计总数时无法正确解析COUNT语句中的子查询,导致总数虚高。
解决方法是针对自定义SQL,在Mapper XML里手写完整的COUNT语句,确保总数查询与分页数据查询完全独立。更简单的办法是把分页查询拆成两步:第一步查符合条件的ID列表,第二步按ID列表查详细数据,虽然多了一次查询,但逻辑上更可控。
7.3 慢查询索引优化实测
工单量积累到几万条后,设备维修报表的查询开始变慢。设备科想要按科室和月份统计维修次数和平均修复时长,我最初用了一个比较复杂的SQL,查询全表扫描,耗时三秒以上。这种报表页面每次打开都转圈,用户体验很差。
优化方式是加组合索引。工单表原本只有id主键,我在department_id和create_time两个字段上建立了联合索引,查询瞬间降到200毫秒以内。这个案例告诉我们,对于报表类查询,不要一开始就想着用缓存,先把数据库索引设计好,效果立竿见影而且零代码修改。
7.4 Redis分布式锁解决双实例重复生成保养工单
上线约一个月后,系统部署方式升级为双实例集群。第二天早上设备科反馈:保养工单全部翻了一倍,工程师收到了一大堆重复任务。原因正如前面提到的,两台实例同时执行@Scheduled任务,各自扫描到相同的过期保养计划,各自生成了相同工单。
解决这个问题我引入了Redis分布式锁。生成保养任务的定时方法在开始时先尝试获取锁,只有拿到锁的实例才能继续执行,另一台实例直接跳过本次任务。
@Component public class MaintenanceScheduler { @Autowired private StringRedisTemplate redisTemplate; @Scheduled(cron = "0 30 2 * * ?") public void generateMaintenanceOrders() { String lockKey = "emms:maintenance:generate"; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(locked)) { log.info("其他实例正在执行保养任务生成,本次跳过"); return; } try { // 原有生成逻辑 } finally { redisTemplate.delete(lockKey); } } }这个方案已经稳定运行了一段时间。要注意的是锁的过期时间要设置得足够长,必须保证业务逻辑能在锁过期之前执行完成,否则可能出现两个实例同时执行的极端情况。我设置10分钟,单次生成几百张工单其实几秒就能完成,时间上留了很大余量。
更新完这个方案后,我又对整个系统做了一遍排查,把其他几个定时任务也统一加了分布式锁。这个教训的根源还是那句老话:凡是涉及定时任务的业务逻辑,在上多实例之前,一定要先考虑幂等性和并发控制,不要等线上出了故障再临时救火。
最后再分享一个小技巧。如果开发设备维护类系统,建议从开始就在工单表里保留一个source字段,标记工单来自人工报修、自动保养还是巡检发现。有了这个维度,后续统计"报修渠道占比""预防性维护贡献率"就非常方便,设备科在向院里汇报工作时,这些数字非常有用。