SSM+MySQL文物管理系统开发实战:表设计、事务与索引优化全解析
2026/9/12 18:53:19 网站建设 项目流程

简介:一份面向毕业设计场景的文物管理系统资料包,适合计算机相关专业学生用于选题参考、二次开发或论文对照。系统以SSM框架为基础,配合Mysql数据库,采用B/S架构并通过JSP完成动态页面;后台覆盖用户管理、文物分类、文物信息、文物外借、文物维修、留言板、论坛交流与系统管理,前台包含首页、文物信息、文物资讯、留言反馈等模块,业务逻辑完整。压缩包内共1331个文件,整体约68.17MB,其中包含大量Java源代码、JSP页面、CSS样式、JavaScript脚本以及PNG、JPG、GIF图片素材,可分别对应后端逻辑、页面显示、前端样式与界面素材;同时附有SQL数据库脚本、Word文档、MP4演示视频等关键内容。配套毕业论文围绕研究现状、开发背景、设计目标、需求分析、系统设计与实现、测试等环节展开论述,配合PPT和开发文档可支撑完整毕设答辩与文档撰写。目前已有63人学习,目录结构清晰,适合快速检索代码、文档与多媒体素材,作为一套完整的毕业设计方案进行参考。

1. 文物管理系统选 SSM + MySQL 来做的原因,先说清楚再动手

文物管理系统这类项目,本质是给文物库房做一本“在线台账”:每件文物的编号、名称、年代、材质、存放位置要随时查得到,借出、归还、修复的状态流转要记得住,不同角色(管理员、库管员、借展方)的权限要分得开。我见过不少翻车案例,都是把 Excel 台账直接搬到网页上:能录入能导出,但一遇到“某件文物现在在谁手里、什么时候该还”就答不上来,因为没有一张表把状态闭环起来。这个系统要解决的正是这件事。

选择 SSM(Spring + SpringMVC + MyBatis)配 MySQL,不是因为这套组合新,而是因为它的复杂度刚好覆盖这个场景。文物的核心操作就是增删改查加一条借还流水,数据量在百万级以内,并发通常只有几十个人同时操作,一个 Tomcat 实例加一台 MySQL 完全够用。Spring 管对象和事务,SpringMVC 接请求和参数,MyBatis 把 SQL 写清楚,MySQL 用 InnoDB 保证事务和行锁,每一层的职责都小而清晰。

这套技术栈在今天仍然值得选的原因是它的可解释性。不管是答辩被问“权限怎么设计的、事务怎么控制的”,还是面试被问“MyBatis 的动态 SQL 怎么防注入”,SSM 的每个决定都能落到具体的类和配置上。微服务和容器编排确实更强,但在这个量级的业务里,它们引入的复杂度远大于收益。下面按环境搭建、表设计、核心流程落库、排错自检这条链路来讲,给出的命令和代码都以实际能跑通为准。

2. 搭建 SSM 工程骨架:三个框架的分工、Maven 依赖与三份配置文件

2.1 Spring 容器管对象,SpringMVC 管请求,MyBatis 管 SQL 的边界

先明确一件事:SSM 不是三个框架各写各的,而是一条请求从浏览器到数据库再返回的完整链路。Spring 把 Service、Mapper 这些 Java 对象交给 IoC 容器管理,并负责给 Service 方法加事务;SpringMVC 只关心 HTTP 层,从请求里取出参数、调用 Service、把返回值写成 JSON 或渲染页面;MyBatis 负责最后一段——把接口方法翻译成 SQL,执行后把结果集映射回 Java 对象。

这条链路上最容易犯的错误是职责越界。比如在 Controller 里直接写 SQL 逻辑,或者把参数校验放在 Service 里用 try/catch 包住再抛出通用异常。常见的规范做法是:Controller 只做参数接收、格式转换和错误响应;Service 做业务校验并保证事务;Mapper XML 只写 SQL,不做 if/else 分支业务。这个边界定清楚之后,后面加功能、换数据库、写单元测试都会省很多事。

MyBatis 与 Spring 的衔接是通过 mybatis-spring 这个桥接包完成的,它负责把 SqlSessionFactory 注册进 Spring 容器,并通过 MapperFactoryBean 动态生成 Mapper 接口的实现类。这一步不配置好,最常见的报错是No qualifying bean of type 'xxxMapper',原因通常是 Mapper 扫描路径和接口包的路径不一致,后面 2.3 节会给出具体配置。

2.2 Maven 依赖:版本对齐是第一步坑

先看 pom.xml 里需要哪些依赖。这里给出的是一个能跑文物管理系统的精简集合,不要贪多:

<properties> <spring.version>5.3.31</spring.version> <mybatis.version>3.5.16</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.3</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies>

几个容易踩的点:第一,mybatis-spring 2.x 只支持 Spring 5.x,如果你把 spring 升到 6.x,就要换 mybatis-spring 3.x,这对毕业设计和中小项目来说没必要升级。第二,MySQL 8 的驱动包名从mysql-connector-java改成了mysql-connector-j,两个都能用,但新项目我一般用后者,避免旧驱动连接 8.0 数据库时出现Public Key Retrieval is not allowed的报错。第三,pagehelper 必须在 mybatis 之后引入,它是以 MyBatis 插件形式工作的。

2.3 三份核心配置文件:applicationContext.xml、springmvc.xml、mybatis-config.xml

工程里一般拆三份 XML,也可以合并,但分开写更清晰。applicationContext.xml 管 Spring 容器、数据源和事务:

<context:component-scan base-package="com.relic"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/relic_db?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.relic.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

逻辑说明:component-scan 用 exclude-filter 排除 Controller,是为了避免 Spring 容器和 SpringMVC 容器重复管理同一个 bean;sqlSessionFactory 里的 mapperLocations 指向 XML 文件目录;MapperScannerConfigurer 把接口自动扫描进容器,这样 Service 里直接@Autowired就能拿到 Mapper。事务管理器接的是同一个 dataSource,否则事务不生效。

springmvc.xml 只管 Controller 那一层:

<context:component-scan base-package="com.relic.controller"/> <mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"/> </mvc:message-converters> </mvc:annotation-driven>

mybatis-config.xml 管 SQL 的全局行为:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <typeAliases> <package name="com.relic.entity"/> </typeAliases> <plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="helperDialect" value="mysql"/> <property name="reasonable" value="true"/> </plugin> </plugins>

mapUnderscoreToCamelCase 让storage_loc自动映射成storageLoc,可以少写大量 resultMap。typeAliases 让 XML 里写resultType="RelicDO"而不是一长串全限定名。PageHelper 在这里注册为插件,比在 Spring 里配置少一步。

2.4 MySQL 8 的安装与连接参数:把连不上和乱码挡在第一天

环境搭建部分,我一般直接用 MySQL 8.0 的最新稳定版安装包,装完后用 MySQL Workbench 或 Navicat 连接测试。如果你机器上已经有 Docker,也可以用一行命令起一个 MySQL 8:

docker run -d --name relic-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=relic_db \ mysql:8.0

注意这里的数据没有挂载卷,只适合开发环境。真正要长期跑的项目,务必加上-v把数据目录挂到宿主机,否则容器一删,库就没了。

连接串是开发期最常见的报错来源。MySQL 8 的 JDBC URL 有四个参数需要固定写对,对照下表:

参数作用
serverTimezoneAsia/Shanghai解决 8 小时时差,避免日期字段错一天
useUnicode / characterEncodingtrue / utf8中文写入不乱码,必须写 UTF-8
useSSLfalse本地开发不需要 SSL 握手,避开证书报错
allowPublicKeyRetrievaltrue配合 caching_sha2_password,避免连不上报错

把这些写在 2.3 节的 dataSource url 里。如果你看到The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized这种乱码报错,就是 serverTimezone 没加。

3. 文物台账的 MySQL 表设计:拆分、字段与索引一次定清楚

3.1 拆表思路:主表、分类表、借还流水表三张起步

文物管理系统的表设计,核心矛盾是“文物属性多、查询条件杂”,但业务体量又不足以支撑复杂的微服务拆分。常见的做法是拆三张表起步:relic_info放文物主信息,relic_category放分类,borrow_record放借出归还流水。修复记录、盘点记录、操作日志量少但增长快,先不建表,放在 remark 或系统日志里,等功能稳定再拆。

为什么不把分类直接塞进主表当冗余字段?因为分类要支持“按瓷器、书画、青铜器筛选”,还要支持二级分类,独立成表用parent_id就能递归表达,查询时 LEFT JOIN 一次,成本很低。借还流水则必须独立,因为一件文物会被多次借出,一对多关系如果放主表,要么冗余行、要么只能留最后一次记录,盘点时完全没法追溯历史。

3.2 建表 SQL:三张表的字段、类型与设计理由

CREATE TABLE relic_category ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '分类ID', category_name VARCHAR(64) NOT NULL COMMENT '分类名称,如瓷器、书画、青铜器', parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示一级', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_category_name (category_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文物分类表'; CREATE TABLE relic_info ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '文物ID', relic_no VARCHAR(32) NOT NULL COMMENT '文物编号,台账唯一', relic_name VARCHAR(128) NOT NULL COMMENT '文物名称', category_id BIGINT UNSIGNED NOT NULL COMMENT '分类ID,逻辑关联分类表', era VARCHAR(32) DEFAULT NULL COMMENT '年代/朝代,如明朝', material VARCHAR(32) DEFAULT NULL COMMENT '材质', grade TINYINT NOT NULL DEFAULT 3 COMMENT '等级:1一级 2二级 3三级 4一般', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1在库 2借出 3修复 4注销', storage_loc VARCHAR(64) DEFAULT NULL COMMENT '库房位置,如A-12-3', entry_date DATE DEFAULT NULL COMMENT '入藏日期', image_url VARCHAR(255) DEFAULT NULL COMMENT '图片路径', description TEXT COMMENT '描述', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0否 1是', 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_relic_no (relic_no), KEY idx_category_id (category_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文物主表'; CREATE TABLE borrow_record ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '流水ID', relic_id BIGINT UNSIGNED NOT NULL COMMENT '文物ID', applicant VARCHAR(64) NOT NULL COMMENT '借用人', apply_dept VARCHAR(64) DEFAULT NULL COMMENT '借用单位/部门', borrow_date DATE NOT NULL COMMENT '实际借出日期', expected_date DATE DEFAULT NULL COMMENT '预计归还日期', actual_date DATE DEFAULT NULL COMMENT '实际归还日期', status TINYINT NOT NULL DEFAULT 1 COMMENT '1借出中 2已归还 3逾期', handler VARCHAR(64) DEFAULT NULL COMMENT '经办人', remark VARCHAR(255) DEFAULT 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), KEY idx_relic_id (relic_id), KEY idx_status_expected (status, expected_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文物借出归还流水表';

几个字段设计上的关键决定:主键用BIGINT UNSIGNED AUTO_INCREMENT,不要用文物编号做主键,因为编号可能因账本修订而变化,且是字符串,InnoDB 聚簇索引对有序整数最友好。relic_no单独建唯一索引,对应实物上的编号,保证一套账里不重复。不建外键约束,只留category_id做逻辑关联,理由是文物注销后分类不能随便删,但主表记录要保留,外键的级联删除行为在这里是负资产。status用 TINYINT 而不是字符串,避免“在库、借出、修复”写错字,取值做成字典,后面代码里用常量引用。

类型上,时间字段全用DATETIMETIMESTAMP到 2038 年就溢出,而且受时区影响会换算,对中文环境的台账系统不友好。entry_date是纯日期,用DATE而不是 DATETIME,查询“某月入藏几件”时不需要处理时间部分。金额、数量在这个系统里基本用不上,如果有预估价值,建议用DECIMAL(12,2)而不是 FLOAT,避免二进制浮点误差。

3.3 索引用在哪三处:mysql 创建索引的完整命令

上面建表时已经带了几个索引,但实际开发过程中经常要补。MySQL 创建索引的常见命令如下:

-- 组合索引:按状态+预计归还日查逾期 ALTER TABLE borrow_record ADD INDEX idx_status_expected (status, expected_date); -- 覆盖查询:按分类+入库时间倒序拿列表 ALTER TABLE relic_info ADD INDEX idx_cat_time (category_id, create_time); -- 查看执行计划,确认索引有没有被用上 EXPLAIN SELECT id, relic_name FROM relic_info WHERE category_id = 2 ORDER BY create_time DESC;

索引选型的判断标准看 where 条件的区分度和组合情况。relic_no唯一索引必建,category_id在按分类筛选时用得上,create_time配合列表倒序分页。像status这种只有 1 到 4 几个取值的字段,单独建索引几乎没有效果,但要和expected_date组成组合索引,让“逾期未还”这种高频统计走覆盖索引。EXPLAIN 里看到type=refkey不为空,基本说明优化器接受了这个索引;看到Using filesort,就检查 ORDER BY 的字段是不是索引的一部分。

另外注意,MySQL 里“更新子查询”有一个经典限制:不能直接UPDATE relic_info SET ... WHERE id IN (SELECT id FROM relic_info WHERE ...),会报You can't specify target table for update in FROM clause。常见做法是再包一层派生表,或者像我 4.3 节那样改成条件更新,从根上绕开这个问题。

3.4 中文模糊查询:like 走不了索引时的替代方案

文物名称、描述的中文查询一般写成LIKE '%青花%',这种前导百分比的写法走普通 B+ 树索引是没有用的,数据量小(几千行)时无所谓,但到十万行级别就会明显变慢。MySQL 8 的 InnoDB 支持中文全文索引,用 ngram 解析器按连续两个汉字切词:

ALTER TABLE relic_info ADD FULLTEXT INDEX ft_relic_name (relic_name) WITH PARSER ngram; SELECT relic_no, relic_name FROM relic_info WHERE MATCH(relic_name) AGAINST('青花' IN NATURAL LANGUAGE MODE);

需要注意 ngram 默认ngram_token_size=2,搜单字“瓷”基本搜不准,且全文索引和LIKE的语义不同,它按词匹配而非子串匹配。我的建议是:列表页模糊查询先用LIKE CONCAT('%', #{keyword}, '%'),配合 Pagination 的小页面数据量够用;当数据量增长、用户明显感觉慢之后,再让搜索请求走全文索引或直接上专门的检索组件,不要在项目第一天就把索引加满。

4. 文物核心流程落库:Controller 校验、Service 事务、Mapper 条件更新

4.1 文物入库:编号查重与业务异常回滚

文物入库是最典型的三层写库流程。Controller 接收参数,Service 做编号查重和基础校验,Mapper 执行插入。代码结构如下:

@RestController @RequestMapping("/api/relic") public class RelicController { @Autowired private RelicService relicService; @PostMapping("/add") public Result add(@RequestBody RelicDTO dto) { // DTO 转 DO,前端传来的字段不会直接拼进 SQL RelicDO relic = new RelicDO(); BeanUtils.copyProperties(dto, relic); relicService.addRelic(relic); return Result.ok("入库成功"); } }
@Service public class RelicServiceImpl implements RelicService { @Autowired private RelicMapper relicMapper; @Override @Transactional(rollbackFor = Exception.class) public void addRelic(RelicDO relic) { int count = relicMapper.countByNo(relic.getRelicNo()); if (count > 0) { throw new BizException("文物编号已存在:" + relic.getRelicNo()); } relicMapper.insert(relic); } }

逻辑说明:查重用COUNT而不是查出整行再判断,省一次结果集映射;事务注解写rollbackFor = Exception.class而不是默认的 RuntimeException,因为查重抛出的 BizException 如果只继承 Exception,不加这个参数就回滚不了;参数校验放在 DTO 上做@NotBlank等注解校验,比在 Service 里 if/else 干净。

对应的 Mapper XML:

<insert id="insert" parameterType="RelicDO" useGeneratedKeys="true" keyProperty="id"> INSERT INTO relic_info (relic_no, relic_name, category_id, era, material, grade, status, storage_loc, entry_date, image_url, description) VALUES (#{relicNo}, #{relicName}, #{categoryId}, #{era}, #{material}, #{grade}, #{status}, #{storageLoc}, #{entryDate}, #{imageUrl}, #{description}) </insert> <select id="countByNo" resultType="int"> SELECT COUNT(*) FROM relic_info WHERE relic_no = #{relicNo} AND is_deleted = 0 </select>

注意 countByNo 里的is_deleted = 0,这是 3.2 节逻辑删除字段的正确用法:查重和后续所有业务查询都要过滤已删除记录,否则删除后再入库同名编号会被误判为已存在。

4.2 分页查询:PageHelper 的 startPage 用法与 ThreadLocal 边界

列表查询是前端的核心交互,分页用 PageHelper 插件,Service 代码非常短:

public PageResult<RelicVO> pageQuery(int pageNum, int pageSize, RelicQuery query) { PageHelper.startPage(pageNum, pageSize); List<RelicVO> list = relicMapper.pageQuery(query); PageInfo<RelicVO> pageInfo = new PageInfo<>(list); return new PageResult<>(pageInfo.getTotal(), pageInfo.getList()); }
<select id="pageQuery" resultType="RelicVO"> SELECT r.id, r.relic_no, r.relic_name, c.category_name, r.era, r.grade, r.status, r.storage_loc FROM relic_info r LEFT JOIN relic_category c ON r.category_id = c.id WHERE r.is_deleted = 0 <if test="keyword != null and keyword != ''"> AND r.relic_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND r.category_id = #{categoryId} </if> ORDER BY r.create_time DESC </select>

PageHelper 的实现原理是 ThreadLocal:startPage把分页参数塞进当前线程,下一次执行的 Mapper SQL 会被拦截器改写成分页语句,执行完后参数被移除。这带来三个必须遵守的边界,也对应三个面试常问点:

规则原因
startPage 后必须紧跟一条查询中间插入其他 SQL 会被这个查询消费掉分页参数
同一个线程里不要并发调用两个查询ThreadLocal 不是线程隔离的,异步线程会串参数
ORDER BY 写在 Mapper XML 里插件在 SQL 末尾追加 LIMIT,后加的排序会被挤到错误位置

reasonable=true的效果是页码越界自动收敛到第一页或最后一页,不会因为用户手改 URL 而报错。PageInfo 里除了 total、list,还有连续页码数组、总页数等字段,前端分页组件直接取用,不需要再手动拼装。

4.3 借出归还:一个事务两条 SQL,条件更新锁住库存

借出归还比入库复杂的地方在于它是“改状态 + 写流水”两步操作,必须在一个事务里完成,并且要防止两个人同时借出同一件文物。先看方案:

@Override @Transactional(rollbackFor = Exception.class) public void borrow(Long relicId, BorrowDTO dto) { // 条件更新:只有“在库”才允许借出,影响行数作为唯一凭据 int updated = relicMapper.updateStatusFromInStock(relicId); if (updated != 1) { throw new BizException("该文物当前状态不可借出"); } int inserted = borrowRecordMapper.insertForBorrow(relicId, dto); if (inserted != 1) { throw new BizException("借出流水写入失败"); } }
<update id="updateStatusFromInStock"> UPDATE relic_info SET status = 2 WHERE id = #{relicId} AND status = 1 AND is_deleted = 0 </update>

核心思路是“条件更新代替先查后改”。两个请求同时走到这段代码时,第一句 UPDATE 会给这一行加行锁,第二个事务会阻塞等待;第一个事务提交后,第二个事务执行 UPDATE 时因status = 1条件不满足,影响行数为 0,直接抛出“不可借出”。用 affected rows 判断结果,就从根上消灭了竞态,这也是很多并发 bug 的修复方向。MySQL 锁表问题大多不是行锁本身,而是事务没及时提交或索引没命中导致锁升级成表锁,所以事务方法里尽量只做必要操作,不要夹带外部接口调用或长时间循环。

归还的逻辑对称处理:更新borrow_record的 actual_date 和 status,再把relic_info状态改回 1。两条更新同样在一个事务里,任何一个失败整体回滚。

4.4 逾期统计的查询写法与存储过程的取舍

逾期查询用于“到归还日还没还的文物”,对borrow_record里的状态做日期过滤:

SELECT r.relic_no, r.relic_name, b.applicant, b.expected_date FROM borrow_record b JOIN relic_info r ON r.id = b.relic_id WHERE b.status = 1 AND b.expected_date < CURDATE() ORDER BY b.expected_date ASC;

这个查询利用组合索引(status, expected_date),status 和 expected_date 都在索引里,回表次数可控。至于“每天自动统计逾期”的定时任务,我一般用 Spring 的@Scheduled在应用层做,而不是 MySQL 存储过程。原因很简单:存储过程里的逻辑没法用单元测试验证,日志不完整,改一处要重新执行 DDL;应用层定时任务可以打印每批处理结果,出问题能快速定位。MySQL 的存储过程不是不能用,只是在这个场景里维护成本高过收益。

5. 上线自检:连接串、乱码、账实核对一次做完

5.1 新驱动、时区、SSL 三个连接参数的对照

把 2.4 节的参数落到实际报错上排查。看到控制台出现Loading class com.mysql.jdbc.Driver. This is deprecated,说明还在用旧驱动类名,把 driverClassName 改成com.mysql.cj.jdbc.Driver。出现Public Key Retrieval is not allowed,检查 URL 是否带allowPublicKeyRetrieval=true。出现时间差 8 小时,检查是不是漏了serverTimezone=Asia/Shanghai。这三个问题占了 MySQL 8 连接故障的大头,自检时按顺序扫一遍即可。

5.2 汉字乱码的排查顺序

文物名称、库房位置这类中文字段乱码,按四层查:第一,JDBC URL 里 characterEncoding=utf8;第二,数据库和表的 CHARSET 是否 utf8mb4,建表 SQL 里已带,但手工改过的旧库要复查;第三,Tomcat 的 URI 编码,在 server.xml 里给 Connector 加URIEncoding="UTF-8",否则 GET 请求的中文参数会乱;第四,页面响应头 charset。排查时最先看 URL,其次看表结构,不要一上来就改代码。

5.3 账实核对 SQL 与并发借出的验证方法

部署完成后,建议用一段对账 SQL 验证状态一致性:主表显示已借出,但流水表里没有对应的未归还记录,说明状态流转的 Update 漏了:

-- 找出状态与流水不一致的文物 SELECT r.id, r.relic_no, r.relic_name FROM relic_info r LEFT JOIN borrow_record b ON b.relic_id = r.id AND b.status = 1 WHERE r.status = 2 AND b.id IS NULL;

事务行为的验证不依赖界面:在 Service 借出方法里临时让第二条 SQL 抛一个运行时异常,跑一遍后检查主表状态是否仍为 1,回滚成功说明@Transactional配置正确。并发借出同一件文物,开两个终端分别执行借出,观察只有一个事务成功拿到状态变更,另一个抛出“不可借出”,说明条件更新的锁行为符合预期。这两条验证通过后再接界面层,能省掉大量接口联调时的排查时间。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询