简介:一份基于Java SpringCloud实现的LIMS样本库实验室管理系统源代码,面向需要落地微服务架构的Java工程师、实验室信息化项目开发者,以及正在学习SpringCloud综合应用的中高级学习者。系统完整覆盖样本接收、存储、检测、分析、报告等全流程管理,并集成权限控制、数据整合与外部系统对接能力。压缩包共199个文件、约11.41MB,包含39个Java源文件与对应class文件、39个Jar依赖、31个XML配置,以及JSP页面、JS/CSS前端资源、SQL脚本等;其中XML兼顾Spring配置与MyBatis映射,JSP/JS/CSS构成管理后台界面,SQL脚本可辅助初始化数据库,源码层级清晰,已有206人学习下载。通过对Eureka服务发现、Zuul网关路由、用户服务、样本服务等核心模块的源码梳理,读者可深入理解微服务拆分、业务解耦与权限控制的具体实现,适合作为实验室管理系统二次开发及SpringCloud综合实战的参考范本。
1. 用 Java SpringCloud 搭 LIMS 样本库:先想清楚业务边界再动手
做实验室系统的同行基本都遇到过这种场面:样本登记靠 Excel 手工编号,条码打印用第三方小工具,样本入库出库查不到历史记录,月底盘点时账实永远对不上。我接手过一家第三方医学检验所的样本库改造,业务量一天两三千管样本,旧系统是单机版 C/S 架构,每管样本从登记到报告要经过五个岗位,靠的是纸质交接和口头沟通。这套 Java SpringCloud 实现的 LIMS 样本库实验室管理系统源码,解决的就是这类场景——把样本从登记、入库、检验任务分配、结果录入到报告生成的全流程串成一条有状态、可追踪、能审计的数据链路,同时保证多机构多角色下的并发一致性和权限隔离。它适合三类人:正在规划微服务架构的实验室内业开发、需要给现网 LIMS 补样本管理模块的 Java 工程师、以及想用真实业务场景练手 SpringCloud 生态的技术负责人。注意,这套东西不是拿来就能跑的成品软件,它是基于微服务思想实现的样本库核心源码,需要你按自己的环境配置和二次开发。
2. 微服务拆分与数据库设计:样本库模块的边界到底划在哪
2.1 服务划分:把一个 LIMS 拆成六个可独立部署的服务
LIMS 这类系统最常见的翻车点,是把所有业务逻辑塞进一个单体 Spring Boot 工程,然后发现样本登记、检测任务、报告模板之间耦合得根本拆不开。这套源码的服务边界划分思路值得直接抄——按业务域而不是按页面拆。我拆过几版之后,稳定的划分方式是六个服务加一个网关。
| 服务名 | 端口 | 核心职责 | 关键表 |
|---|---|---|---|
| gateway | 8080 | 路由转发、Token 鉴权、接口限流 | 无 |
| system-service | 8101 | 用户、角色、菜单、机构管理 | sys_user, sys_role, sys_org |
| sample-service | 8102 | 样本登记、入库、出库、状态流转 | sample_info, sample_status_log |
| test-service | 8103 | 检测任务分配、项目配置、结果录入 | test_task, test_item, test_result |
| report-service | 8104 | 报告生成、模板管理、审核签发 | report_info, report_template |
| stock-service | 8105 | 样本库位、库存记录、库存预警 | stock_location, stock_record |
网关单独拆出来的原因很直接:样本的登记和查询是高频接口,而报告生成是低频重负载,两者放在同一进程里,报告导出时 GC 停顿会拖垮登记接口。拆开后各自水平扩展,互不干扰。实际部署时,常见做法是每个服务两个实例起 Docker 容器,Nginx 指到网关,网关再按路径前缀分发到各服务。这是这套源码里推荐的最小集群形态,后续要加采集仪器对接服务,也是往这个链路里塞一个新服务的事。
2.2 样本表结构设计:状态字段、关联关系、以及唯一约束
样本库最核心的表是 sample_info,设计得不好后面每个查询都难受。这份源码里的设计有几个值得记的点。首先是样本编号,业务上要求一个样本一个号、扫码枪扫了就能定位,所以 sample_no 上建了唯一索引,而且编号规则是「机构代码 + 日期 + 六位流水号」。第二是状态字段 status,用 tinyint 而不是 varchar,代码里用枚举类映射,避免魔法数字散落在业务代码里。
CREATE TABLE `sample_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `sample_no` VARCHAR(32) NOT NULL COMMENT '样本编号,唯一', `org_id` BIGINT NOT NULL COMMENT '机构ID', `sample_type` TINYINT NOT NULL COMMENT '样本类型:1-血液 2-尿液 3-组织', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-已登记 2-在库 3-检测中 4-已出报告 5-已废弃', `source` TINYINT NOT NULL DEFAULT 1 COMMENT '来源:1-门诊 2-住院 3-体检', `patient_id` BIGINT DEFAULT NULL COMMENT '关联患者ID', `location_id` BIGINT DEFAULT NULL COMMENT '库位ID', `collect_time` DATETIME DEFAULT NULL COMMENT '采样时间', `receive_time` DATETIME DEFAULT NULL COMMENT '实验室接收时间', `out_time` DATETIME DEFAULT NULL COMMENT '出库时间', `create_by` VARCHAR(32) NOT NULL COMMENT '创建人', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sample_no` (`sample_no`), KEY `idx_status` (`status`), KEY `idx_org_receive` (`org_id`, `receive_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本主表';这个表结构的逻辑说明:unique key 保证了并发登记时同一个编号不会写入两条,这是幂等的最底层保障。idx_status 单独建索引是因为样本列表页最常用的筛选条件是状态,这个查询频率远高于其他条件。idx_org_receive 联合索引是给机构维度的接收时间范围查询用的,比如「查询某机构最近三天的登记样本」。注意这里没有用外键约束,跨服务的表关联靠业务字段——org_id 关联 system-service 的机构表,patient_id 关联患者主数据,这是微服务架构下的常见做法,为了服务自治放弃数据库外键,换取的是拆分部署时不用处理库表之间的物理依赖。
没有单独的样本扩展字段表,是因为这类系统在第一次上线后很快会面临「同一个样本要多存几项结果」的需求。源码里预留了一个 JSON 字段 ext_info,用 MySQL 5.7 的 JSON 类型存储特殊项目批次、冻融次数这些结构化程度不高的属性,避免大面积改表。这个取舍很实用,值得保留。
2.3 Nacos 注册与配置:服务发现和配置中心的落地写法
微服务之间调用的第一步,是让 sample-service 能找到 test-service。这套源码用的注册中心是 Nacos,配置分两层:bootstrap.yml 放 Nacos 地址和命名空间,application.yml 放业务配置。注意 Nacos 的命名空间一定要建,环境隔离就靠它——同一个 Nacos 集群上跑 dev、test、prod 三个命名空间,DEVOPS 推广不下去的项目往往就是因为这套隔离没做。
# bootstrap.yml spring: application: name: sample-service cloud: nacos: server-addr: 192.168.1.10:8848 username: nacos password: nacos namespace: prod-01 discovery: group: LIMS_GROUP config: group: LIMS_GROUP file-extension: yml# application.yml 在 Nacos 配置中心里维护的部分 server: port: 8102 spring: datasource: url: jdbc:mysql://192.168.1.20:3306/lims_sample?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: lims_app password: ENC(abc123...) driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 192.168.1.30 port: 6379 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这段配置的关键点有两个。第一个是 group 保持一致:服务的 discovery 和 config 都写了 LIMS_GROUP,不同服务如果 group 不一致,服务到服务之间会发现不了。第二个是密码加密:配置中心里不落明文密码,常见做法是 jasypt 加密后用 ENC() 包裹,启动时通过 -Djasypt.encryptor.password=xxx 注入密钥。每台服务器上的密钥放在环境变量里,代码库里只保留密文,避免配置文件泄露导致数据库权限丢失。这些看着是小事,但生产环境出过乱子之后就会明白,配置项的值错了服务起不来,配置项的值泄露了整个库都保不住,后者比前者可怕得多。
3. 样本核心流程实现:从登记到出库的状态机代码
3.1 状态机定义:为什么用状态码而不是直接改字段
样本的整个生命周期本质上是一个状态机:登记后进入待入库,入库后变成在库,检验科接单后变成检测中,结果审核通过后变成已出报告,不合格或超期就变成已废弃。这套源码把状态流转收敛到了一个地方,而不是散落在各个 service 里随手 setStatus。这么做的好处,是你可以写出一个完整的流转矩阵,哪些路径合法、哪些路径非法,一目了然。
合法流转路径只有这几条:1→2(登记后入库)、2→3(在库样本被检测任务取走)、3→4(检测完成并出报告)、2→5(在库样本超期废弃)、1→5(登记作废)。除此之外都是非法操作,比如 3→2 就是样本还没有出结果就回到在库,这在业务上意味着检测中断,必须有强制备注才能执行。源码里用一个 StatusFlowValidator 组件承载这套规则,每次更新状态前先校验合法性,不合法直接抛业务异常。
3.2 样本登记接口:用策略模式处理三种样本类型
样本登记在不同机构里采集信息不一样:门诊样本要关联患者主索引,体检样本要关联批次号,科研样本要记录捐赠协议编号。如果用 if else 嵌套,每加一种样本类型就改一遍主流程,代码很快变成无人敢碰的状态。这套源码里做了策略模式,把不同样本类型的登记逻辑拆成独立实现,主流程只依赖接口。
public interface SampleRegisterHandler { // 返回该策略支持的样本类型 SampleType type(); // 登记前的业务校验,例如门诊样本校验患者是否存在 void validate(SampleRegisterDTO dto); // 执行入库逻辑 void handle(SampleRegisterDTO dto); }@Component public class OutpatientSampleHandler implements SampleRegisterHandler { @Override public SampleType type() { return SampleType.OUTPATIENT; } @Override public void validate(SampleRegisterDTO dto) { // 门诊样本必须关联有效患者 if (dto.getPatientId() == null) { throw new BizException("门诊样本必须关联患者"); } } @Override public void handle(SampleRegisterDTO dto) { // 生成样本编号:机构代码+日期+流水 dto.setSampleNo(sampleNoGenerator.generate(dto.getOrgId())); // 初始化样本状态为已登记 saveSample(dto); // 写入状态流转日志,记录初始状态 statusLogService.append(dto.getSampleNo(), SampleStatus.REGISTERED, null); } }调用侧把 Spring 容器里所有 SampleRegisterHandler 的实现注入到一个 Map 里,key 是样本类型,value 是处理器实例,登记时直接按类型取。
@Service public class SampleRegisterService { private final Map<SampleType, SampleRegisterHandler> handlerMap; public SampleRegisterService(List<SampleRegisterHandler> handlers) { this.handlerMap = handlers.stream() .collect(Collectors.toMap(SampleRegisterHandler::type, Function.identity())); } @Transactional(rollbackFor = Exception.class) public void register(SampleRegisterDTO dto) { SampleRegisterHandler handler = handlerMap.get(SampleType.of(dto.getSampleType())); if (handler == null) { throw new BizException("不支持的样本类型"); } handler.validate(dto); handler.handle(dto); } }这段实现里有两个点值得说明。第一,handlerMap 用 Spring 注入 List 再转 Map,以后新增一种样本类型时只加一个 @Component 类,不用改注册服务一行代码。第二, @Transactional 加在注册服务上而不是单个 handler 上,核心原因是一个样本登记动作可能涉及主表写入、状态日志写入、库存初始记录写入,任何一个失败都要整体回滚。方法切面标注 rollbackFor = Exception.class,是为了让自定义业务异常也能触发回滚——Spring 默认只在 RuntimeException 上回滚,如果你抛出 BizException 且它继承了 Exception 而不是 RuntimeException,不指定这个参数就可能出现主表没写进去但状态日志写了的情况,非常隐蔽。听起来像玄学,但这种问题是真实发生过的,血泪经验。
3.3 入库与出库:库存数量的事务控制
样本入库不只是改样本状态,还要在 stock_record 里增加一条「某样本从某库位入库」的记录,出库则相反。这两个动作跨了 sample_info 主表和 stock_record 流水表,必须在同一个事务里完成。此外,同类样本在同一个库位的库存总数是实时查询统计出来的,高频并发下很容易出现库存数量对不上的情况。源码里做了一个很实用的设计:在库位的维度加一个 version 字段,每次库存变更时比对版本号,防止并发覆盖。
@Transactional(rollbackFor = Exception.class) public void inbound(StockInboundDTO dto) { // 1. 更新样本状态 SampleInfo sample = sampleMapper.selectByNo(dto.getSampleNo()); if (sample == null) { throw new BizException("样本不存在"); } // 2. 更新库位库存,乐观锁控制并发 StockLocation location = stockLocationMapper.selectByIdForUpdate(dto.getLocationId()); if (location == null) { throw new BizException("库位不存在"); } int updated = stockLocationMapper.increaseStock( dto.getLocationId(), 1, location.getVersion()); if (updated == 0) { throw new BizException("库位库存更新冲突,请重试"); } // 3. 写入库存流水 StockRecord record = new StockRecord(); record.setSampleNo(dto.getSampleNo()); record.setLocationId(dto.getLocationId()); record.setAction(StockAction.INBOUND); stockRecordMapper.insert(record); }这个入库逻辑的细节在于 selectByIdForUpdate 和 increaseStock 的组合。selectByIdForUpdate 是悲观锁,先锁住库位行,保证后续两步操作之间没有其他事务插入,适合单个库位的严格一致场景。increaseStock 在 SQL 层面用 version 做条件更新,返回 0 表示版本已被其他事务修改,直接抛异常让用户重试。业务上我倾向在样本入库这种低频但要求强一致的场景用悲观锁,而在批量出库这种高频场景改成乐观锁,两种策略都在源码里有对应实现,根据接口调用频率灵活切换。如果你只用了乐观锁,两管样本同时入同一个空库位,后提交的事务会一直冲突直到放弃,调用方体验就很差;只用了悲观锁,同一库位的并发扣减容易形成锁等待,数据库连接池很快被占满,两类锁搭配使用才是这个场景的正解。
4. 批量导入与条码打印:样本库运维效率的关键实现
4.1 Excel 批量导入:用 EasyExcel 做校验与幂等
样本库上线三个月后,最大的日常工作量是批量补录历史样本。手工一条条录,一天能录三百条就算快,而且极易在样本编号和采样时间上录错。源码用 EasyExcel 写了一个批量导入组件,核心诉求是两件事:格式错误提前拦截,和重复导入不产生脏数据。
public class SampleImportListener extends AnalysisEventListener<SampleImportRow> { private final List<SampleImportRow> validRows = new ArrayList<>(); private final List<String> errorMessages = new ArrayList<>(); private final Set<String> existingNos; private static final int BATCH_SIZE = 500; public SampleImportListener(Set<String> existingNos) { this.existingNos = existingNos; } @Override public void invoke(SampleImportRow row, AnalysisContext context) { // 行级校验:必需字段不为空、日期格式合法、状态值在枚举范围内 if (row.getSampleNo() == null || row.getSampleNo().trim().isEmpty()) { errorMessages.add("第" + context.readRowHolder().getRowIndex() + "行:样本编号为空"); return; } if (existingNos.contains(row.getSampleNo().trim())) { errorMessages.add("第" + context.readRowHolder().getRowIndex() + "行:样本编号已存在"); return; } validRows.add(row); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 所有行解析完毕后统一入库 if (validRows.isEmpty()) { return; } for (int i = 0; i < validRows.size(); i += BATCH_SIZE) { List<SampleImportRow> batch = validRows.subList(i, Math.min(i + BATCH_SIZE, validRows.size())); sampleMapper.batchInsert(batch); } } }把这个监听器接入导入接口时,existingNos 是提前从数据库查出的全部样本编号集合,一次性放到内存里用于判重。文件量低于十万条时这种方案够用,但如果你的库里有上百万历史样本,一次性查全量编号会 OOM,常见做法是改成分批查询,每解析一百行查一次数据库,查不到的才放行。这个组件里配的是一个可切换的判重器,内存版和数据库版各有实现,按数据规模选型。还有一点需要注意,EasyExcel 解析时每行触发 invoke 方法,如果你在 invoke 里直接调用 mapper 插入,数据量大时事务会很大,而且解析中断时已插入的数据无法回滚,所以源码是在 doAfterAllAnalysed 里统一批量插入,解析和入库两个阶段彻底分离,出错时定位也更容易。
4.2 条码生成:扫码枪与标签打印的编码坑
样本的条码在扫码枪扫出来的那一瞬间,最容易出问题的是两个点:字符集和解析协议。管子上贴的条码基本都是 Code128,因为密度高、支持字母数字混合、扫码枪兼容性好。源码用 ZXing 生成一维条码图片,然后按标签模板排版打印。生成条码时几个参数要设对:宽度 300 像素、高度 80 像素、是否带可读字符设置为 true——这样下方会显示一列数字,方便条码磨损后人工识别。
public BufferedImage generateBarcode(String content) { // 样本编号是纯数字时用 CODE128B,混合字符时用 CODE128C Code128Writer writer = new Code128Writer(); Map<EncodeHintType, Object> hints = new HashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.MARGIN, 10); BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.CODE_128, 300, 80, hints); BufferedImage image = new BufferedImage(300, 80, BufferedImage.TYPE_INT_RGB); // 将 BitMatrix 渲染到 BufferedImage return image; }这里真正的坑在于样本编号如果包含汉字,Code128 是编码不了的,扫码枪扫出来会乱码。所以样本编号规则里只允许大写字母、数字和连字符,从源头上规避解析问题。还有一个现场经常遇到的问题:贴标机打印几十个标签后,标签上的条码发虚,扫码枪识别不了。排查看下来往往是图片按百分比缩放导致像素拉伸,ZXing 生成的图片是 300x80,打印驱动再用 72dpi 和 300dpi 转换,条码线条就糊了。源码里直接把条码图片按 1:1 输出到打印机,不走缩放路径,这个问题就消失了。这些属于看着小、影响面却很大的细节,做过打印标签的现场工程师应该都有共鸣。
4.3 库存预警:定时任务扫表的实现
样本库最怕超期样本没人管:血液样本常温下只能放几天,组织样本冻存时间长了结果也会受影响。源码在 stock-service 里做了定时任务,每天凌晨扫一次全库的在库样本,把超期未出库的样本和低于下限的库位单独拉成预警清单,第二天早上检验科上班第一眼就能看到。
@Component public class StockAlertJob { private final StockLocationMapper stockLocationMapper; private final SampleInfoMapper sampleInfoMapper; @Scheduled(cron = "0 30 2 * * ?") public void scanExpiredSamples() { // 查出所有在库样本 List<SampleInfo> inStockSamples = sampleInfoMapper.selectByStatus(SampleStatus.IN_STOCK); // 按库位分组统计,对比库存下限 Map<Long, Long> stockCountMap = inStockSamples.stream() .collect(Collectors.groupingBy(SampleInfo::getLocationId, Collectors.counting())); List<StockAlert> alerts = new ArrayList<>(); stockLocationMapper.selectAll().forEach(location -> { long currentCount = stockCountMap.getOrDefault(location.getId(), 0L); if (currentCount < location.getMinThreshold()) { StockAlert alert = new StockAlert(); alert.setLocationId(location.getId()); alert.setAlertType(StockAlertType.BELOW_MIN); alert.setCurrentCount(currentCount); alert.setMinThreshold(location.getMinThreshold()); alerts.add(alert); } }); // 批量插入预警记录 if (!alerts.isEmpty()) { stockAlertMapper.batchInsert(alerts); } } }定时任务本身不复杂,但有几个参数值得细调。cron 表达式0 30 2 * * ?表示每天凌晨 2:30 执行,选这个时间点是因为凌晨做完整库扫描对数据库的压力最大,而这个时段业务几乎为零。如果你库里的样本量超过五十万条,全表扫描和分组统计会造成长时间慢查询,常见做法是改成增量扫描:每天只扫前一天有状态变更的样本,再配合一个每周一次的全量扫描做兜底。预警记录生成后,接入企业微信或钉钉机器人推送是另一个扩展点,源码里留了 Webhook 配置项,往那边发一个 JSON POST 就可以收到样本超期和库位不足的通知,现场运维非常依赖这个。
5. SpringCloud 踩坑清单:部署和读写样本数据时常见的五个问题
5.1 现象:服务间调用超时,样本数据重复提交
用户点一次「登记样本」按钮,转了两分钟圈,然后页面提示失败,用户再点一次,结果样本库里出现两条相同编号的记录。这种问题在微服务架构下特别常见。原因是 gateway 调用 sample-service、sample-service 调用 test-service 的链路中,某个环节网络抖动,OpenFeign 默认的读超时只有 1 秒,连接超时 2 秒,源码里如果忘记配置超时时间,一次正常的批量登记接口可能因为 GC 停顿就被判定为超时。
解决:网关层面加全局超时配置,sample-service 调用 test-service 的重试次数设置为 0,改为由上游调用方做幂等控制。样本登记接口增加请求幂等键,前端每次点击生成一个 requestId,后端在 Redis 里按 requestId 判重,同样的请求在 30 分钟内只处理一次。这是标准解法,但注意重试次数不能盲目加大——样本登记不是纯查询,重试可能导致一管样本在未出库时被重复扣减库存,宁可失败让用户重试,也不要自动重试造成资金或库存方面的对不上。
5.2 现象:数据库连接池被打满,接口大面积超时
样本批量导入功能上线第一天,导入一万条记录时数据库连接池直接耗尽,所有查询接口变成几十秒响应。原因是批量导入使用了线程池并发处理 Excel 分片,每个线程都从连接池获取连接,而连接池默认最大值是 10,五十个线程同时跑就把连接池打满了。这是一个典型的「并发规划没配到系统资源上」的问题。
解决:按并发线程数调 HikariCP 参数。maximumPoolSize 调到线程池大小加 20 左右,minimumIdle 保持 10 即可。另一个更稳的做法是把导入流程改成生产者消费者模式,Excel 解析完成的行先写入本地消息表,再由消费者单线程批量入库,数据库压力完全可控。这套源码里两个模式都有,小数据量直接用并发导入,大数据量切到生产者消费者,切换点在两万条。
5.3 现象:Nacos 配置修改了不生效,重启也不行
在 Nacos 控制台改了一个 MySQL 连接参数,等了五分钟服务还是旧值,重启也一样。最常见的原因是修改的 dataId 和服务实际读取的配置不对应。Nacos 的 dataId 命名规则是${spring.application.name}.${file-extension},如果你在控制台新建配置时写的是 sample-service.yaml,而服务端的 spring.cloud.nacos.config.file-extension 配置的是 yml 后缀,那这个配置永远不会被加载。还有一个隐蔽点:如果你同时在 bootstrap.yml 里配了 config.group,控制台新建配置时 group 必须一致,标签页里默认的 DEFAULT_GROUP 往往被忽略。
解决:统一 dataId 命名规则,所有环境都用${spring.application.name}.yml,group 固定为 LIMS_GROUP,不要两种写法换着用。配置修改后先看日志里是否有 Fetching config from server 的记录,确认服务确实拉取到了新配置,再继续下一步。如果是敏感配置修改后不生效,还要检查是否开了 spring.cloud.nacos.config.refresh-enabled=false,这个开关关了以后配置中心推送只是存下来,不会触发上下文刷新。
5.4 现象:样本入库成功了,但库存流水缺失
跨服务调用时出现的数据不一致,是微服务架构里最让人头疼的问题之一。样本入库需要在 sample-service 更新样本状态,同时在 stock-service 记录库存流水,如果两边的服务网络隔离,或者其中一个服务事务回滚,就会出现样本是「在库」状态但查不到入库流水。这种问题一旦发生,后续所有库存统计都是错的,且极难追溯。
解决:在跨服务的写操作之间引入分布式事务。源码里适配了 Seata AT 模式,sample-service 执行本地事务时同时注册分支事务,stock-service 的库存操作也作为分支事务参与全局事务,任何一个分支失败都会触发逆向前置镜像回滚。如果你的业务允许最终一致,也可以用本地消息表:sample-service 写完样本状态后往消息表插一条待发送的流水事件,然后定时把事件投递到 stock-service,消费成功就更新消息状态,重试到一定次数就触发人工核对。两种方案我都用过,强一致场景直接 Seata,重量级但可靠;高吞吐场景用消息表,注意消息表中的事件内容要包含完整的样本编号和库位 ID,方便失败时人工补录。
5.5 现象:样本列表深分页越来越慢
样本列表页翻到几百页之后,接口响应从 100 毫秒涨到 3 秒。MySQL 中 limit 的深分页是已知的性能黑洞:limit 100000, 20 会先扫描前 100020 行再丢弃前 100000 行,代价巨大。业务上线初期没人会翻到这么深,但当样本量冲到几百万时,这个翻页问题就是必然出现的。
解决:改掉深分页,换成三种常见方案。第一种是游标分页,列表接口接受 lastId 参数,查询条件变成 where id > lastId order by id asc limit 20,翻页性能不再受页数影响。第二种是限定最大翻页深度,超过一百页强制用户使用时间范围过滤,从业务上把深分页场景消灭掉。第三种是对于默认列表页,直接限定只查最近一个月的数据,历史数据走专门的查询入口。源码里默认用的是游标分页,改造工作量不大,却是样本库能长期扛住数据增长的保障。
6. 进阶技巧:用一个临时表和批量更新接口把库存校准做到分钟级
盘库是样本库每个月固定要做的苦差事。实物数一遍,系统里导出一遍,两边对不上的地方全靠人工逐条核对,几百条差异记录往往要核一整晚。这个技巧是把校准过程拆成一个「两阶段对账」,把差异核对成本降到分钟级。
第一阶段,把所有库位和样本清单导入一张临时表 stock_count_actual,字段就设三个:location_id、sample_no、counted_status。盘点人员在手机上按库位扫码录入实物状态,录完点击上传,数据进临时表。第二阶段,写一个批量对账接口,把临时表和样本主表按照库位 ID join,逐个比对样本编号集合和样本状态。两边一致的就跳过,不一致的生成差异清单,最后在页面上一键确认后再批量更新样本主表状态。
@Transactional(rollbackFor = Exception.class) public List<StockDiff> reconcile() { // 1. 从临时表读出实际库存,按库位分组 List<ActualStock> actualList = actualStockMapper.selectAllGroupByLocation(); // 2. 从样本主表读出系统库存,按库位分组 List<SystemStock> systemList = sampleInfoMapper.selectInStockGroupByLocation(); // 3. 逐库位比对,生成差异记录 List<StockDiff> diffs = new ArrayList<>(); for (SystemStock sys : systemList) { ActualStock actual = findActual(actualList, sys.getLocationId()); if (actual == null || actual.getCount() != sys.getCount()) { StockDiff diff = new StockDiff(); diff.setLocationId(sys.getLocationId()); diff.setSystemCount(sys.getCount()); diff.setActualCount(actual == null ? 0 : actual.getCount()); diffs.add(diff); } } return diffs; }这里的核心不是 SQL 有多复杂,而是把对账逻辑做成一个可反复执行的独立接口,盘库人员随时可以触发,差异清单直接在页面上标注「以实物为准」或「以系统为准」,确认后批量更新样本状态。执行时注意两个验证步骤:第一步,先核对参与对账的样本范围一致——临时表里的样本编号必须全部存在于样本主表,否则一盘库就会出现大量「系统没有但实物有」的记录,原因往往是新到的样本还没入库就贴了条码上架;第二步,对账完成后跑一个统计查询,连续三次核对「各库位总数 = 各状态样本数之和 + 差异数」,对上了才算成功。
这个校准接口上线后,盘库时间从原来的四小时压到了半小时以内。从那以后我每次上线涉及样本状态变更的接口,都会强制走一遍这个对账流程:先导出一份变更前后的样本状态快照,再模拟一次差异清单生成,确认没有多改、漏改才敢发布。这套方法帮我在后面的三个实验室项目里少踩了无数次数据不一致的坑,希望帮到你。
本文还有配套的精品资源,点击获取