拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot实现样本库LIMS:从数据模型到并发追溯的实战指南

Spring Boot实现样本库LIMS:从数据模型到并发追溯的实战指南

简介:一份基于Java与Spring Boot技术栈实现的样本库实验室管理系统LIMS完整源码,面向Java后端开发、实验室信息化建设者以及毕业设计人员。系统覆盖样本登记与分类、实验过程记录、用户角色权限、报表统计和外部系统集成等核心模块,展示了Spring Boot结合Spring Data JPA/MyBatis、Spring Security、Spring MVC实现数据持久化、RESTful API与安全控制的企业级实践。资源共966个文件,压缩包9.83MB;其中234个Java源码构成后端业务逻辑,54个Vue组件与181个JavaScript脚本共同支撑前端交互,CSS及SVG/PNG图标完成界面展示,另含SQL初始化脚本、yml/properties配置和Dockerfile,便于本地部署与二次开发。目前已有432人学习下载。借助该套源码可深入理解LIMS系统的分层目录结构、数据表设计与权限拦截流程,适合用于课程设计、项目实训或快速搭建实验室管理原型。

1. 样本库为什么要上 LIMS:一个 Spring Boot 项目能解决的三件事

生物样本库的管理有一个很现实的痛点:样本数量一旦过千,"样本在哪、状态如何、做过哪些检测"就变成三笔糊涂账。冰箱里的冻存管标签会冻脆脱落,Excel 里的位置登记会记错行,两个项目组同时申请一管样本也时有发生。LIMS(实验室信息管理系统)把台账线上化,让每一管样本从采集、入库、分装到出库报告都有电子轨迹。用 Java + Spring Boot 实现 LIMS 是当前最顺手的技术选型,Spring 家族的事务、依赖注入、安全框架直接覆盖这个场景的大部分需求。这篇笔记按我做样本库 LIMS 的落地思路展开,从数据模型讲到核心接口实现,再把并发和追溯几个坑单独拉出来说,适合正在设计这类系统或刚接手 LIMS 源码的开发者。

2. 用 Spring Boot 搭 LIMS 骨架:先把六张核心表和事务边界定下来

LIMS 这类系统业务规则多但技术难点分散,真正的复杂度全在数据模型上。表关系没定好,后面每加一个需求都要动表结构,迁移脚本写到怀疑人生。所以动手写 Controller 之前,先把核心表结构定下来,再决定 Service 层怎么切事务。

2.1 六张必须一次建对的表:样本、冻存盒、位置、批次、检测、报告

样本库 LIMS 的管理对象是"样本",但真正约束日常操作的是"位置"。你每天要回答的问题是某一管样本放在哪个冻存盒的哪一格,这一格现在有没有被别的样本占着。围绕这个核心,我建议第一版就建这六张表:

表名职责关键字段与其他表的关系
sample样本主记录sample_code、sample_type、status、quantity、batch_id、box_cell_id一对一到 box_cell
freezer_box冻存盒/冻存架box_code、box_type、row_count、col_count一对多到 box_cell
box_cell具体存放位置box_id、row_no、col_no、status一对一到 sample
batch入库批次batch_code、source、received_at、operator一对多到 sample
test_result检测结果sample_id、project_name、result_value、unit、test_time多对一到 sample
report报告记录report_no、sample_id、file_path、status一对一到 sample

这里最容易忽略的是box_cell.status:它既要表示"空闲/占用",又会在样本出库后变回"空闲",这时样本和位置的一对一关系不能丢。我的做法是让sample.box_cell_id带上唯一约束(一个位置只能被一个样本占用),这样即使并发分配也不会出现两个样本共用一个坐标。sample_code必须唯一,它是冻存管上的条码内容,查询、导出、审计全部依赖这个字段。sample_type建议用字符串枚举而不是数字,后面第 4 章会专门讲为什么。

2.2 用 Spring Data JPA 落地 Sample 实体:字段类型与约束选择

JPA 在这个场景最大的好处是让你把表结构和 Java 对象放在一处维护,建表脚本由ddl-auto控制。生产环境我会把ddl-auto设为validate,表结构用 Flyway 管理,但开发期用update快速迭代没问题。Sample 实体的核心写法如下:

package com.example.lims.sample; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "sample", indexes = { @Index(name = "idx_sample_status", columnList = "status"), @Index(name = "idx_sample_batch", columnList = "batch_id") }) public class Sample { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "sample_code", nullable = false, unique = true, length = 64) private String sampleCode; @Column(name = "sample_type", nullable = false, length = 32) private String sampleType; @Column(name = "status", nullable = false, length = 20) @Enumerated(EnumType.STRING) private SampleStatus status; @Column(name = "quantity", nullable = false, precision = 10, scale = 2) private BigDecimal quantity; @Column(name = "unit", length = 10) private String unit; @Column(name = "batch_id") private Long batchId; @Column(name = "box_cell_id", unique = true) private Long boxCellId; @Column(name = "created_by", length = 32) private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; /** 分配位置后由 Service 层调用,不开放 setter 给外部随意改 */ public void occupyCell(Long cellId) { this.boxCellId = cellId; this.status = SampleStatus.STORED; } @PrePersist public void prePersist() { if (createdAt == null) { createdAt = LocalDateTime.now(); } if (status == null) { status = SampleStatus.REGISTERED; } } }

关键参数说明如下。@Enumerated(EnumType.STRING)让数据库存的是状态名而不是数字,查询时一眼能看懂数据,后期加状态也不用改存量数据;代价是字段会略大,对 LIMS 这种数据量级别完全可接受。box_cell_id上的unique = true是并发防线,比任何应用层判断都可靠。quantity用BigDecimal而不是double,样本量可能精确到 0.01 毫升,浮点误差在财务和实验数据上都不能容忍。@PrePersist里做默认值填充,省去每个 Service 方法里重复写setCreatedAt的麻烦。

2.3 Service 层为什么比 Controller 重要:事务与业务规则

Spring Boot 的 Controller 通常很薄,业务规则集中在 Service。原因有两个:一是事务注解@Transactional必须落在 Service 方法上,Controller 里写业务会让事务边界模糊;二是 LIMS 的业务规则经常跨多张表,例如样本出库要同时改sample.status、释放box_cell.status并写一条审计日志,这些必须在一个事务里完成。

package com.example.lims.sample; import com.example.lims.exception.BusinessException; import com.example.lims.box.BoxCellRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class SampleService { private final SampleRepository sampleRepository; private final BoxCellRepository boxCellRepository; public SampleService(SampleRepository sampleRepository, BoxCellRepository boxCellRepository) { this.sampleRepository = sampleRepository; this.boxCellRepository = boxCellRepository; } @Transactional public Sample registerSample(String sampleCode, String sampleType, BigDecimal quantity) { if (sampleRepository.findBySampleCode(sampleCode).isPresent()) { throw new BusinessException("样本条码已存在: " + sampleCode); } Sample sample = new Sample(); sample.setSampleCode(sampleCode); sample.setSampleType(sampleType); sample.setQuantity(quantity); return sampleRepository.save(sample); } @Transactional public void discardSample(Long sampleId) { Sample sample = sampleRepository.findById(sampleId) .orElseThrow(() -> new BusinessException("样本不存在")); // 只有已入库的样本才能销毁,避免跨状态流转 if (sample.getStatus() != SampleStatus.STORED) { throw new BusinessException("当前状态不允许销毁: " + sample.getStatus()); } // 释放位置 if (sample.getBoxCellId() != null) { boxCellRepository.releaseCell(sample.getBoxCellId()); } sample.setStatus(SampleStatus.DISCARDED); sample.setBoxCellId(null); // 审计日志记录 auditLogService.record("SAMPLE_DISCARD", sampleId, "样本销毁"); sampleRepository.save(sample); } @Transactional(readOnly = true) public Sample getSampleByCode(String sampleCode) { return sampleRepository.findBySampleCode(sampleCode) .orElseThrow(() -> new BusinessException("样本不存在")); } }

这段代码值得注意的点:registerSample在保存前自己查了一次重码,这只是友好的前置校验,真正的唯一性由数据库唯一索引兜底;discardSample里先校验状态再释放位置再做状态变更,三个操作在一个事务里,中途任何一步抛异常整体回滚,不会出现"位置释放了但样本状态还是已入库"这种数据不一致。@Transactional(readOnly = true)加在只读查询上,数据库连接会走只读路由,对 MySQL 也能让优化器做一些只读相关的优化。把这些规则收在 Service 层,Controller 里就不会出现散落的if (status == 2)判断,后续加新业务规则时改动点很集中。

3. 样本从登记到报告的核心流程:批量导入、位置分配与 Word 报告

骨架搭好后,接下来是 LIMS 里最常被问到的三段流程:批量导入样本、冻存盒位置分配、检测报告生成。这三段恰好覆盖了"进、存、出"的主链路,写清楚它们的代码和参数,基本就拿到了这套源码的钥匙。

3.1 批量导入 Excel 样本清单:EasyExcel 读取与分批落库

样本库首次上线或批量接收新样本时,用户手里通常是一张 Excel,几十到几千行不等。用 EasyExcel 读取比 POI 手动解析省事得多,它按行映射 DTO,内存占用也低。我的做法是读取后均匀切片,每批 200 条单独开事务保存,避免一个大事务锁住太多行。

package com.example.lims.sample; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.annotation.ExcelProperty; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.math.BigDecimal; import java.util.List; @Service public class SampleImportService { /** Excel 行与 Java 字段的映射 */ public static class SampleImportRow { @ExcelProperty("样本条码") private String sampleCode; @ExcelProperty("样本类型") private String sampleType; @ExcelProperty("体积") private BigDecimal quantity; @ExcelProperty("单位") private String unit; public String getSampleCode() { return sampleCode; } public void setSampleCode(String sampleCode) { this.sampleCode = sampleCode; } public String getSampleType() { return sampleType; } public void setSampleType(String sampleType) { this.sampleType = sampleType; } public BigDecimal getQuantity() { return quantity; } public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } public String getUnit() { return unit; } public void setUnit(String unit) { this.unit = unit; } } private final SampleService sampleService; public SampleImportService(SampleService sampleService) { this.sampleService = sampleService; } public int importExcel(MultipartFile file) throws IOException { List<SampleImportRow> rows = EasyExcel.read(file.getInputStream()) .head(SampleImportRow.class) .sheet(0) .doReadSync(); final int BATCH_SIZE = 200; for (int i = 0; i < rows.size(); i += BATCH_SIZE) { List<SampleImportRow> batch = rows.subList(i, Math.min(i + BATCH_SIZE, rows.size())); int imported = batch.size(); // 每条数据单独 try-catch,失败记录但不中断整体导入 for (SampleImportRow row : batch) { try { sampleService.registerSample(row.sampleCode, row.sampleType, row.quantity); } catch (Exception ex) { // 记录到导入错误清单,最终返回给前端 importErrors.add(row.sampleCode + ": " + ex.getMessage()); } } } return importedCount; } }

参数说明:doReadSync()同步读取全部行,适合几千行的场景;如果文件达到几十万行,需要用invokeHeadRow监听器做流式读取,但样本库 LIMS 一般到不了这个量级。批次大小 200 是我常用的值,太大容易触发 MySQL 锁等待和 undo log 膨胀,太小又浪费事务开销。每条数据单独 try-catch 是导入功能必须做的防御——一行条码重复不应该让后面 500 行全部失败,用户需要的是"成功了多少、失败清单是什么"。

3.2 冻存盒位置分配:并发安全才是最关键的

位置分配是 LIMS 最容易被并发击穿的地方。两个操作员同时导入样本,程序同时查到同一个空闲坐标,各自执行更新就会产生脏数据。解决思路是让数据库来仲裁:box_cell上建唯一索引,查询时用悲观锁把候选行锁住,MySQL 8.0 还支持SKIP LOCKED,让并发事务直接跳过被锁住的行去选下一格。

package com.example.lims.box; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; public interface BoxCellRepository extends JpaRepository<BoxCell, Long> { /** 锁住一个空闲位置并返回,SKIP LOCKED 让其他事务跳过被锁行 */ @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(value = "SELECT * FROM box_cell WHERE box_id = :boxId " + "AND status = 0 ORDER BY row_no, col_no LIMIT 1 FOR UPDATE SKIP LOCKED", nativeQuery = true) List<BoxCell> findFirstIdleCellForUpdate(@Param("boxId") Long boxId); /** 分配后立即置为占用 */ @Query("UPDATE BoxCell c SET c.status = 1 WHERE c.id = :cellId AND c.status = 0") int occupyCell(@Param("cellId") Long cellId); }

这段 SQL 里FOR UPDATE SKIP LOCKED是关键:没有SKIP LOCKED时,一行被锁,其他事务的FOR UPDATE会一直等待,造成不必要的阻塞;加上之后,并发分配会各自拿到不同的空闲位置,吞吐量明显提升。findFirstIdleCellForUpdate的返回是List而不是Optional,因为 JPA 对LIMIT 1的原生查询返回列表更可靠,方法内取第一条即可。occupyCell用的是条件更新WHERE c.status = 0,如果并发下状态已被其他事务改成占用,影响行数为 0,可以据此判断需要重新分配。

注意@Lock配合nativeQuery = true时,部分数据库方言不支持自动拼接FOR UPDATE,所以我把FOR UPDATE SKIP LOCKED直接写进了 SQL。这种方式依赖 MySQL 8.0 以上版本,如果是 MySQL 5.7,备选方案是把box_cell_id的唯一索引作为兜底,发生唯一约束冲突时捕获异常重试。JPA 遇到这种并发冲突会抛出DataIntegrityViolationException,重试逻辑放在 Service 层,最多重试三次。

3.3 检测结果录入与报告生成:用 POI 输出可存档的 Word 报告

检测结果这块相对直接:一个样本做多个项目,每个项目一条test_result记录,通过sample_id关联。报告生成则是把结果汇总成一份正式文档。用 Apache POI 的XWPFDocument操作 Word 模板是成本最低的方案,实验室主任审核通过后直接导出 PDF 归档。

package com.example.lims.report; import org.apache.poi.xwpf.usermodel.*; import java.io.FileOutputStream; import java.time.LocalDate; import java.util.List; public class ReportGenerator { public void generate(String reportNo, String sampleCode, String sampleType, List<TestResult> results, String outputPath) throws Exception { try (XWPFDocument doc = new XWPFDocument()) { // 标题段落 XWPFParagraph title = doc.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun titleRun = title.createRun(); titleRun.setText("样本检测报告"); titleRun.setBold(true); titleRun.setFontSize(18); // 样本信息段落 XWPFParagraph info = doc.createParagraph(); info.createRun().setText("报告编号: " + reportNo); info.createRun().setText(" 样本条码: " + sampleCode); info.createRun().setText(" 样本类型: " + sampleType); info.createRun().setText(" 报告日期: " + LocalDate.now()); // 结果表格 XWPFTable table = doc.createTable(1, 4); table.getRow(0).getCell(0).setText("检测项目"); table.getRow(0).getCell(1).setText("结果值"); table.getRow(0).getCell(2).setText("单位"); table.getRow(0).getCell(3).setText("检测时间"); for (TestResult r : results) { XWPFTableRow row = table.createRow(); row.getCell(0).setText(r.getProjectName()); row.getCell(1).setText(r.getResultValue().toPlainString()); row.getCell(2).setText(r.getUnit()); row.getCell(3).setText(r.getTestTime().toString()); } // 落款 XWPFParagraph sign = doc.createParagraph(); sign.createRun().setText("检测人: " + results.get(0).getOperator()); sign.createRun().setText(" 审核人: ____________"); try (FileOutputStream out = new FileOutputStream(outputPath)) { doc.write(out); } } } }

POI 操作 Word 参数上最常踩的是文字和表格的样式设置,setFontSize单位是磅值,18 对应小初号,标题够醒目。表格默认宽度偏窄,需要时可以table.setWidth("100%")或者用CTTblWidth设置百分比,否则导出到 Windows 上打开会出现表格挤压。落款处留审核人签名位是行业惯例,报告没有审核签名不能归档。至于 PDF 转换,常见做法是 LibreOffice 命令行无头转换,也可以走iText直接生成 PDF,但 Word 模板改动更灵活,推荐先用 POI 出 Word 再转 PDF。

4. 样本库 LIMS 避坑指南:并发、追溯、批量导入和状态设计的 5 个真实问题

这一章把我在实际开发和排查中遇到的高频问题整理出来,每条按"现象 → 原因 → 解决"来写。这些问题不解决,系统在小规模时看着正常,样本量一涨就会集中爆发。

4.1 现象一:两个样本抢到了同一个冻存盒坐标

现象是线上数据库出现两条 sample 记录指向同一个box_cell_id,冻存盒台账显示该位置被两个样本同时占用,后续出库时完全无法判断谁该拿这一管。

原因是分配位置的代码没有做并发保护。常见写法是先查空闲位置再 update 状态,两个事务同时查到同一个status = 0的行,各自执行 update 互相覆盖。如果sample.box_cell_id上没建唯一索引,这个问题在数据库层就不会报错。

解决分两层。第一层是sample.box_cell_id加唯一索引,这是最后的保险。第二层是位置查询用FOR UPDATE SKIP LOCKED,见第 3.2 节代码。两层都做了之后,并发冲突要么拿不到位置,要么在唯一索引处抛出异常,绝不会静默产生错误数据。

4.2 现象二:样本误删后追溯链断裂,复查无法解释

现象是质量复查时需要核对某批样本的完整操作记录,发现关键样本的数据已经不在库里,检测结果和报告成了孤儿数据。

原因是样本记录被物理删除了,调用的是sampleRepository.deleteById(...)。JPA 的delete会直接执行DELETE FROM sample,而test_result表没有外键级联约束,结果留在原地,样本主表没了。

解决是全面禁止物理删除。sample表加deleted字段(默认 0),所有查询默认过滤deleted = 0,删除操作只是把deleted改成 1。同时在SampleService里加审计日志,discardSample、updateSample这类方法写入audit_log表,记录操作人、操作时间、变更前后内容。这样无论谁做了什么,都能从审计日志还原现场。我一般还会在application.yml里配一次spring.jpa.properties.hibernate.jdbc.batch_size,把审计日志的批量写入打开,否则日志量大时性能会掉得很难看。

4.3 现象三:5000 条样本批量导入直接超时

现象是导入 Excel 时请求执行了 30 多秒还没返回,最后报Lock wait timeout exceeded,后台日志能看到大量InnoDB锁等待记录。

原因是整个导入过程放在一个大事务里,5000 条样本逐条insert,加上审计日志和位置预分配,事务执行时间远超innodb_lock_wait_timeout的默认值 50 秒。更糟的是长事务会阻塞其他正常业务请求,比如操作员在界面上做单条登记也会卡住。

解决是把导入改成"拆批提交"。我在 3.1 节代码里用BATCH_SIZE = 200切分,每个批次调用一次带@Transactional的 Service 方法,批次之间提交间隔短,锁持有时间大幅缩短。同时把每条数据单独 try-catch,失败行单独记录,不让脏数据影响整个批次。导入功能完成后要在界面上明确提示"成功 4800 条、失败 200 条、失败原因见下载文件",用户才敢信任这个功能。

4.4 现象四:状态字段用魔法数字,加需求后改动失控

现象是系统运行一年后要加"已销毁"状态,发现 Java 代码里散落大量if (status == 1)、where status = 2,数据库里存的是 0 和 1,新状态只能接在 2 后面,但有的地方用status > 1判断已废弃样本,新状态把逻辑全打乱了。

原因是 DB 字段用了tinyint,Java 里直接用数字比较,没有统一的状态建模。短期看着省事,长期维护成本极高。

解决是状态用枚举并在数据库存字符串。@Enumerated(EnumType.STRING)映射后,数据库值变成了REGISTERED、STORED、DISCARDED这种可读文本,查询条件写where status = 'STORED',一目了然。Java 侧比较用枚举常量SampleStatus.STORED而不是数字。另外推荐给状态流转加校验规则——比如从"已出库"不能直接跳到"已销毁",必须经过"在库"状态,这层校验写在 Service 层的transitionTo方法里,让状态流转有据可查。

4.5 现象五:Controller 返回 JSON 时炸出 LazyInitializationException

现象是访问样本列表接口时,部分带关联字段的请求报了LazyInitializationException,控制台提示could not initialize proxy - no Session,偶尔还伴随failed to lazily initialize a collection。

原因是实体关联了FetchType.LAZY的字段,比如sample.batch或sample.box,在 Service 层的@Transactional结束后,Controller 层组装 JSON 时 JPA 的 Session 已经关闭,懒加载无法执行。Spring Boot 2 之后的open-in-view默认开启反而掩盖了这类问题,等到生产环境并发一高,连接被长期占用,问题更隐蔽也更危险。

解决是项目中约定 Controller 只返回 DTO,不让实体直接进入 JSON 序列化。如果暂时用实体返回,就在 Service 查询时用@EntityGraph(attributePaths = {"batch", "box"})主动抓取关联。第三种方法是@Transactional在 Service 方法上一直保持到 JSON 序列化完成,但这是把数据库连接绑到了网络 IO 上,并发上来必出事,我不建议。处理这类问题最好的时机就是在创建实体时就把FetchType.LAZY和 DTO 转换作为默认约定写进团队规范。

5. 上线前这样验证:Actuator 健康检查、最小链路测试和造数脚本

系统开发完到上线之间,我会固定做三件事:确认健康检查端点可用、用 MockMvc 把主链路接口完整跑一遍、批量造一份模拟数据验证查询性能。这套验证做完,心里才有底。

5.1 用 Actuator 暴露健康端点

management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: always

这个配置让/actuator/health返回服务状态和数据库连接状态,show-details: always是关键,默认never时看不到数据库是否正常。上线后配监控系统定时轮询这个端点,服务是否健康一目了然。

5.2 MockMvc 跑通一条最小链路

@SpringBootTest @AutoConfigureMockMvc class SampleFlowTest { @Autowired MockMvc mockMvc; @Test void fullFlow() throws Exception { // 登记样本 mockMvc.perform(post("/api/samples") .contentType("application/json") .content("{\"sampleCode\":\"S-TEST-001\",\"sampleType\":\"BLOOD\",\"quantity\":1.5}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.sampleCode").value("S-TEST-001")); // 分配位置 mockMvc.perform(post("/api/samples/S-TEST-001/allocate") .param("boxId", "1")) .andExpect(status().isOk()); // 录入检测结果并生成报告 mockMvc.perform(post("/api/samples/S-TEST-001/results") .contentType("application/json") .content("{\"projectName\":\"HBV-DNA\",\"resultValue\":12.3,\"unit\":\"IU/mL\"}")) .andExpect(status().isOk()); } }

这条测试把"登记 → 分配 → 录结果"走了一遍,接口层面验证 Spring 的依赖注入、事务注解和数据库连接都正常。jsonPath断言可以顺带验证字段名有没有改错,尤其是把实体改成 DTO 之后,字段名不一致会在这里立刻暴露。

5.3 造数脚本:用循环快速生成测试样本

IntStream.range(1, 1001).forEach(i -> { String code = String.format("S-TEST-%04d", i); sampleService.registerSample(code, "BLOOD", new BigDecimal("1.0")); });

造数时注意条码格式要符合实际规则,否则后续导出的条码贴纸无法扫描。我用过之后发现,造数脚本跑完要执行一次ANALYZE TABLE sample,让 MySQL 更新统计信息,否则查询优化器可能选了错的索引。

最后说个习惯:我每次拿到新接手的 LIMS 源码,第一件事不是跑起来,而是先看sample表有没有唯一索引、有没有deleted字段、Service 层有没有@Transactional。这三个点能看出这个系统在并发和追溯上有没有认真做过,也能快速判断后续要补多少课。样本库系统的核心资产是数据的可追溯性,代码能跑只是起点,出事能查才是真本事。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表