
简介面向Java Web初学者和SSM开发者这份资源完整演示了SpringSpringMvcMybatis框架下图片上传、入库与回显的落地流程。项目从Controller接收MultipartFile、本地文件存储、Mybatis Mapper写入图片元数据到前端用img标签动态展示形成一套可直接复用的闭环实现并附cet.sql建表脚本方便快速初始化环境。压缩包共120个文件、约17.5MB以53个jar依赖、18个xml配置、13个java源码和11个jsp页面为主同时包含class编译文件、properties配置和sql脚本基本覆盖SSM项目运行所需。已有5471人学习下载。资源还兼顾了文件类型校验、路径安全等防恶意上传细节对理解文件上传完整链路、积累实战排错经验都很有价值。1. SSM(SpringSpringMvcMybatis)图片上传保存到数据库与回显sql这个项目到底在验证什么能力SSM(SpringSpringMvcMybatis)图片上传保存到数据库与回显sql听起来是烂大街的课程设计但它真正考验的东西一点都不过时一张图片从浏览器出发进入 SpringMvc 的 Controller被封装成 MultipartFile经过 MyBatis 的 INSERT 落成数据库里一行 LONGBLOB再通过一条 SELECT 把二进制捞回来最终渲染成页面上的一个 img 标签。这条链路里任何一环没接好你看到的就是“上传成功但图不显示”这种诡异现象而问题往往不在浏览器而在后端的 Content-Type、MyBatis 的 jdbcType甚至是 form 表单漏了一个 enctype。如果你是第一次接触 SSM 的开发者这个标题值得完整做一遍它能把分层、参数绑定、事务边界一次讲透如果你在带新人或做内部小系统这条链路也可以当作快速验证团队基础能力的入门题。生产环境里我们通常不会把图片二进制塞进数据库但搞清楚它为什么不行、什么规模下又能用才是做这个项目的真正收获。2. 建表 SQL 与依赖准备先定图片在数据库里的形态再写上传代码动手写代码之前有两个决定会影响后续所有实现图片内容以什么形式入库以及 SpringMvc 用哪个解析器接管文件上传。这两个问题都在“写第一行 Controller 之前”就该定下来否则后面会反复改表结构、改 Mapper、改回显方式改到怀疑人生。2.1 选型对照LONGBLOB 存二进制还是 MEDIUMTEXT 存 Base64图片存数据库常见做法就两条路直接把二进制字节塞进 LONGBLOB 字段或者把图片转成 Base64 字符串存进 TEXT/MEDIUMTEXT 字段。两条路都能实现“保存到数据库与回显”但后续的 SQL、Mapper、回显代码完全不同。存储方案数据库字段类型回显方式体积开销适用场景byte[] 二进制LONGBLOBController 动态输出图片流img 的 src 指向图片接口与原图体积一致课程设计、内部系统、需要保留原始文件信息Base64 文本MEDIUMTEXT接口返回 data:image/jpeg;base64,xxximg 的 src 直接写这一串比原图多约三分之一小图片、接口直接吐 JSON、不想额外写一个流式接口我一般会优先选 LONGBLOB 方案。原因有三个第一它不增加额外编码开销Base64 会把每 3 个字节变成 4 个字符数据库膨胀 33%图片只要上 1MB这 1MB 的字符串传输和序列化都会拖慢接口第二LONGBLOB 方案里图片类型、文件名、大小都能作为独立字段保存回显时能精确控制 Content-Type第三课程设计和面试演示里面试官想看的往往就是“二进制怎么进库、怎么出库”LONGBLOB 这条链路更完整。Base64 方案也有它的价值后面回显章节我会单独演示但主链路按 LONGBLOB 走。2.2 建表 SQLt_image 表结构与两个容易被忽略的字段选定 LONGBLOB 后表结构就围绕“图片本身 图片元信息”来设计。下面是这个项目完整可执行的建表脚本我通常会把建库语句也放进去方便在本地 MySQL 里一键初始化。CREATE DATABASE IF NOT EXISTS ssm_image_demo DEFAULT CHARACTER SET utf8mb4; USE ssm_image_demo; CREATE TABLE t_image ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT 主键, image_name VARCHAR(100) NOT NULL COMMENT 上传时的原始文件名, image_data LONGBLOB NOT NULL COMMENT 图片的二进制内容, image_type VARCHAR(50) NOT NULL COMMENT MIME类型例如image/jpeg、image/png, image_size INT NOT NULL COMMENT 图片字节数, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 上传时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图片上传演示表;这段 SQL 里有三个点容易被新手跳过。第一image_type 不是可有可无回显时要用它来设置 HTTP 响应头里的 Content-Type存成 image/jpeg 还是 image/png 直接决定浏览器把响应当图片渲染还是当乱码下载写死成 image/jpeg 会在传 PNG 时翻车。第二image_data 用 LONGBLOB 而不是 BLOBBLOB 最大只有 64KB手机拍一张照片就超了LONGBLOB 上限 4GB课程设计场景下足够。第三image_size 虽然技术上可以不算但列表页展示、后续做图片体积校验都靠它省掉以后再补就是一次表结构变更。如果坚持走 Base64 方案只需要把 image_data 的字段类型从 LONGBLOB 换成 MEDIUMTEXT其余字段保持不变Mapper 里的 jdbcType 也要同步调整这个差异在下一章会专门说明。2.3 Maven 依赖与 MultipartResolver 配置版本组合和一个不能改名的 Bean图片上传要能工作SpringMvc 必须知道“谁来解析 multipart 请求”。在 SSM 项目里最经典、踩坑最少的组合是 commons-fileupload 配合 CommonsMultipartResolver。先看 pom 里需要哪些依赖dependencies !-- Spring 全家桶 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.x/version /dependency !-- MyBatis 与 Spring 整合 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.x/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.x/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.x/version /dependency !-- 文件上传解析器 -- dependency groupIdcommons-fileupload/groupId artifactIdcommons-fileupload/artifactId version1.4/version /dependency /dependencies版本这里我写的是大版本区间实际用 5.3.x 配 3.5.x、mybatis-spring 2.x 是一套经过大量项目验证的组合JDK8 环境下很稳。不推荐一上来就上 Spring 6Spring 6 的包名从 javax 换成了 jakartaServlet 容器要求也变了很多课程设计的 Tomcat 版本根本跑不起来白白增加环境成本。依赖加好之后SpringMvc 的配置文件里必须注册文件上传解析器bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namedefaultEncoding valueUTF-8 / property namemaxUploadSize value5242880 / property namemaxUploadSizePerFile value2097152 / /bean三个配置项都有实际意义。defaultEncoding 必须写成 UTF-8否则中文文件名上传后可能乱码maxUploadSize 是单次请求总大小上限5242880 就是 5MBmaxUploadSizePerFile 是单个文件大小上限2097152 即 2MB。这里的 5MB 和 2MB 是我建议的起步值图片内容入库的场景本身就不适合大文件2MB 的图片转成 Base64 字符串都够页面喝一壶了所以把限制设在外面比让 MySQL 去扛更有性价比。还要强调的是这个 bean 的 id 必须叫 multipartResolver不能改成别的名字DispatcherServlet 初始化时会按这个名字查找名字不对文件上传解析器就不会生效RequestParam(file) 直接拿不到值。这种问题报错还不太明显经常被归类到“玄学”里实际上是配置命名没对齐。3. 从 MultipartFile 到 INSERT写通上传链路的三层代码依赖和表结构就绪后就可以开始写上传主链路了。这里的核心不是代码多复杂而是每一层只干自己该干的事页面负责提交 multipart 表单Controller 负责接收文件和组装实体Service 负责业务校验和事务控制Mapper 只负责把参数写进 SQL。3.1 前端表单enctype 才是上传的第一个秘密前端页面别急着上那些花哨的富文本组件先老老实实写一个原生表单把提交行为跑通再往上叠体验。下面是这个项目最简单的一版上传页% page contentTypetext/html;charsetUTF-8 languagejava % html head titleSSM 图片上传/title /head body h2SSM 图片上传演示/h2 form action${pageContext.request.contextPath}/image/upload methodpost enctypemultipart/form-data input typefile namefile acceptimage/* requiredrequired / button typesubmit上传图片/button /form pa href${pageContext.request.contextPath}/image/list查看已上传图片/a/p /body /htmlform 表单里最容易漏的就是 enctypemultipart/form-data。普通表单默认的 application/x-www-form-urlencoded 只会把文件内容变成一串文本字符提交后端收到后根本组装不出 MultipartFile。这个属性不写Controller 里 RequestParam(file) 拿到的是 null或者直接抛 MissingServletRequestPartException。input 的 name 值也必须和后端参数名一致我习惯统一叫 file简单直接。acceptimage/* 只是浏览器给用户的文件选择提示它没有任何安全作用后端的文件类型校验一句都不能省。3.2 ControllerRequestParam 绑定文件redirect 防重复提交Controller 是整个链路的编排者不要在这一层写业务逻辑。图片接收、实体组装、跳转方向都放在这里核心代码如下Controller RequestMapping(/image) public class ImageController { private final ImageService imageService; public ImageController(ImageService imageService) { this.imageService imageService; } PostMapping(/upload) public String upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return redirect:/image/uploadPage?errorempty; } ImageEntity entity buildEntity(file); imageService.saveImage(entity); return redirect:/image/list; } private ImageEntity buildEntity(MultipartFile file) { ImageEntity entity new ImageEntity(); entity.setImageName(StringUtils.getFilename(file.getOriginalFilename())); entity.setImageType(file.getContentType()); entity.setImageSize((int) file.getSize()); try { entity.setImageData(file.getBytes()); } catch (IOException e) { throw new RuntimeException(读取上传文件失败, e); } return entity; } }这里几个细节值得说明。file.isEmpty() 是上传链路的第一道检查比直接调用 getBytes() 更安全空文件没必要进数据库。StringUtils.getFilename 是 Spring 自带的工具方法它的作用是剥离路径信息因为部分浏览器和前端框架提交时会把完整路径带上来比如 C:\fake\folder\a.png不处理的话这个路径会直接存进数据库回显列表里出现一长串垃圾字符。entity 的 imageType 直接取 file.getContentType()这一步就保证了后续回显时不同格式图片都能拿到正确的 MIME。最后返回 redirect 而不是 forward是为了避免用户刷新页面时表单重复提交这个习惯在图片上传场景里尤其重要一次重复提交就是一张重复的图片数据。3.3 Service 与 Mapper事务只包 INSERTBLOB 要显式声明 jdbcTypeService 层这一层把事务和业务判断收拢在一起。Mapper 的 insert 方法需要特别注意 jdbcType 的写法否则个别 JDBC 驱动会报 no typehandler 错误。三个文件一起看Service public class ImageServiceImpl implements ImageService { private final ImageMapper imageMapper; public ImageServiceImpl(ImageMapper imageMapper) { this.imageMapper imageMapper; } Override Transactional public void saveImage(ImageEntity image) { if (image.getImageData() null || image.getImageData().length 0) { throw new IllegalArgumentException(图片内容为空); } imageMapper.insert(image); } Override public ImageEntity getImageById(Integer id) { return imageMapper.selectById(id); } Override public ListImageEntity listImages() { return imageMapper.selectList(); } }public interface ImageMapper { int insert(ImageEntity image); ImageEntity selectById(Param(id) Integer id); ListImageEntity selectList(); }insert idinsert parameterTypecom.demo.po.ImageEntity INSERT INTO t_image (image_name, image_data, image_type, image_size) VALUES (#{imageName}, #{imageData,jdbcTypeBLOB}, #{imageType}, #{imageSize}) /insert select idselectList resultTypecom.demo.po.ImageEntity SELECT id, image_name, image_type, image_size, create_time FROM t_image ORDER BY id DESC /select select idselectById resultTypecom.demo.po.ImageEntity SELECT id, image_name, image_data, image_type, image_size, create_time FROM t_image WHERE id #{id} /select这段代码里有一个最关键的设计selectList 不查 image_data 字段。列表页通常只需要文件名、类型、大小这些元数据如果 SELECT * 一次把整张表的 LONGBLOB 全端出来图片一多页面会卡到失去响应内存直接被打满。正确做法是列表只返回元数据img 标签的 src 指向详情接口由详情接口按需把单条 image_data 查出来回显。insert 语句里的 #{imageData,jdbcTypeBLOB} 是血泪经验MyBatis 默认的 byte[] TypeHandler 大多能处理二进制但显式声明 BLOB 类型可以绕开不同驱动之间的映射差异报错概率明显下降。如果实体里的字段不是 byte[] 而是 Blob 对象这里还要换成 BlobTypeHandler所以我在实体里统一用 byte[]最简单也最可控。selectById 查出来的 image_data 会由 MyBatis 自动映射回实体的 byte[] 属性回显时直接取用即可。4. 让图片从数据库回到浏览器流式回显与 Base64 内联两条路图片入库之后回显是另一个独立工程。这里要回答的问题只有一个img 标签的 src 到底指向什么。答案是两种一种指向一个后端接口后端把 LONGBLOB 作为图片流写回响应另一种是后端把图片转成 Base64 字符串直接发给前端前端把它塞进 data URI。两条路我都走过适用场景完全不同。4.1 方式一ResponseEntity 输出图片流Content-Type 从数据库读流式回显是我在 SSM 项目里的默认方案它对列表页和大图更友好。Controller 里再加一个方法GetMapping(/{id}) public ResponseEntitybyte[] image(PathVariable(id) Integer id) { ImageEntity image imageService.getImageById(id); if (image null || image.getImageData() null) { return ResponseEntity.notFound().build(); } MediaType mediaType MediaType.parseMediaType(image.getImageType()); return ResponseEntity.ok() .contentType(mediaType) .contentLength(image.getImageData().length) .body(image.getImageData()); }这个方法值得注意的细节有三个。第一Content-Type 一定用 image.getImageType() 动态获取不要写死 image/jpeg数据库里存了 image/png 的图片响应头却写 jpeg浏览器能否显示完全看运气这就是很多人说的“PNG 图回显出来是黑屏”的常见原因。第二controller 里同时存在 /list 和 /{id} 两个方法时不会冲突Spring 的路径匹配会优先选择更具体的 /list不会把 list 当成 id 传进去。第三contentLength 让浏览器提前知道图片大小大图加载时能正常显示进度状态。如果 Client 端通过方式访问浏览器会自动携带 Accept 头后端不需要额外处理。4.2 方式二Base64 字符串回显JSON 接口的备选方案如果你的接口设计是“前端拉一个 JSON直接在页面渲染”或者图片本身很小Base64 方案可以省掉一次图片请求。后端转一下即可GetMapping(/base64/{id}) ResponseBody public MapString, Object base64(PathVariable(id) Integer id) { ImageEntity image imageService.getImageById(id); String base64Src data: image.getImageType() ;base64, Base64.getEncoder().encodeToString(image.getImageData()); MapString, Object result new HashMap(); result.put(name, image.getImageName()); result.put(src, base64Src); return result; }前端拿到数据后img 的 src 就是 result.src不需要再发一次图片请求。这个方案的优点是链路短、跨域也方便缺点是每张图片都会产生一份膨胀 33% 的字符串图片一旦上了几百 KB接口响应体和页面体积会同时变大列表页超过五张图的时候浏览器渲染时间会肉眼可见地变长。所以我的习惯是单图预览、小图头像、接口直接返回 JSON 的场景用 Base64列表页、大图、需要浏览器缓存图片的场景坚决用流式回显。4.3 回显路径与静态资源配置别让 DispatcherServlet 吃掉图片请求SSM 项目常见的坑之一是部署后页面样式和图片全部 404问题出在 web.xml 里 DispatcherServlet 拦截了 / 路径把静态资源请求也送进了 SpringMvc。图片接口既然走 /image/{id}就应确保它只被 Controller 处理而 css/js/静态图片放行给容器默认 Servlet。SpringMvc 配置里加一行即可mvc:default-servlet-handler/ mvc:resources mapping/static/** location/static//default-servlet-handler 会把 SpringMvc 没匹配到的请求交给容器默认 Servlet 处理这样 /static 下的 css、js 不受影响。需要注意它的副作用一旦开启所有未匹配的路径都不会再报 404 而是交给容器所以 Controller 里的路径映射必须写准确否则一个手误的 /image/listt 会直接打到容器上得到一个莫名其妙的响应。5. 图片入库与回显避坑清单这条链路上我翻过车的 5 个真实场景图片上传这条链路短但每一环都有它的脾气。下面这些坑没有一个是小众冷门全是实战里反复出现的典型问题每一条我都按现象、原因、解决三个步骤整理遇到同样问题可以直接对着排查。5.1 上传按钮点了没反应后端报 Part 缺失现象前端表单一切正常文件也能选中但提交后 SpringMvc 直接抛 MissingServletRequestPartException或者 RequestParam(file) 拿到的是 null。原因form 标签没有声明 enctypemultipart/form-data。浏览器默认用 application/x-www-form-urlencoded 编码提交文件内容会被当成普通文本字段发送MultipartFile 根本不会被构建出来。这个问题在硬编码 HTML 时最容易犯因为写 form 的时候容易顺手抄一个没有 enctype 的模板。解决在 form 标签上补上 enctypemultipart/form-data。如果用的是 AJAX 提交需要 new FormData() 然后把 FormData 对象作为 body 传给 xhr不要手动设置 Content-Type 为 application/json也不要手动把文件塞进 JSON 字符串。5.2 数据库里有图片数据img 标签却一片空白现象上传流程走完数据库里能看到 image_data 字段有值列表页也渲染出了记录但图片区域空白。打开浏览器开发者工具图片请求的状态码是 200响应体却是一段乱码。原因后端返回 byte[] 时没有正确设置 Content-Type。常见写法是 Controller 方法返回 byte[] 但只加了 ResponseBody没有指定 produces或者方法返回值类型是 StringSpring 把 byte[] 转成了字符串输出浏览器拿到后无法识别成图片。解决用 ResponseEntitybyte[] 写法显式设置 contentType。不要依赖方法上的 produces 属性因为图片类型存在数据库里动态读出来再设置才可靠。我排查的时候第一步都是看响应头里的 Content-Type 是什么如果显示 text/html 或者 application/json就说明返回链路根本没走到图片渲染这条路。5.3 MySQL 报 PacketTooBigException小图没事大图一插就挂现象几百 KB 的图片上传成功一张 1MB 以上的图片插入时报错错误信息里有 Max allowed packet 字样数据库连接随之断开。原因这是 MySQL 服务端的限制不是 Java 或 MyBatis 的问题。MySQL 默认的 max_allowed_packet 比较小单条 INSERT 语句超过这个大小就会被服务端拒绝LONGBLOB 字段里的图片数据正是典型的“大包”。很多人改代码改了半天其实问题在数据库参数上。解决临时调大服务端参数SET GLOBAL max_allowed_packet 64 * 1024 * 1024;或者直接在 my.ini / my.cnf 里写死成 64M。这只是兜底手段真正的工程解法是在应用层限制上传大小上一章已经提到了 CommonsMultipartResolver 里的 maxUploadSizePerFile把它设成 2MB从入口挡住超限图片。5.4 列表页越来越慢看一眼监控发现内存快被打满现象图片只有几十张列表页打开要好几秒服务器内存曲线明显上涨甚至频繁 Full GC。原因列表查询把 image_data 字段也查出来了。SELECT * 会把每张图片的 LONGBLOB 内容全部加载进内存几十张图就是几百 MBMyBatis 再把它们逐个映射成 byte[]内存自然遭不住。这是我在前面反复强调 selectList 不查 image_data 的原因。解决列表查询 SQL 只写元数据字段id、image_name、image_type、image_size、create_time 这几列就够页面展示真正要显示图片时img 的 src 再单独请求详情接口。这个设计也能捎带解决列表页图片展示的懒加载问题用户没滚动到的地方不触发图片请求。5.5 图片显示成下载而不是预览F12 一看到响应头就明白了现象浏览器直接访问图片地址图片没有在页面里展示反而弹出了文件下载框或者在 img 标签里打开是正常的单独访问却触发下载。原因响应头里出现了 Content-Disposition: attachment或者返回的 Content-Type 被设置成了 application/octet-stream。attachment 会强制浏览器下载octet-stream 则让浏览器无法判断用什么方式渲染。解决图片回显接口的响应头应该是 Content-Disposition: inline同时 Content-Type 用数据库里的 image_type。检查方法很简单用 curl -I 看响应头如果 Content-Type 是 image/jpeg 且没有 attachment浏览器就会正常渲染。开发期顺手给图片接口加上 Cache-Control: max-age86400一天之内相同的图片请求不会再打回数据库对 DB 压力是明显的缓解。6. 最后留一个进阶习惯把图片存储抽象成接口再做两个验证项目跑通之后我最想建议你做的不是急着加功能而是把图片存取这一层重新封装一下。上面的实现里 Controller 直接依赖 ImageServiceService 直接依赖 ImageMapper一旦将来你想从“数据库存图片”换成“磁盘存文件”或者“对象存储”Controller、Service、Mapper 要改一大片。常见的做法是抽出一个 ImageStore 接口把保存、读取、删除三个动作定义清楚public interface ImageStore { String save(byte[] imageData, String imageType); byte[] load(String id); void delete(String id); }Controller 的回显方法只依赖 ImageStore不再关心后端是查表还是读文件。数据库实现、磁盘实现、对象存储实现各自完成自己的逻辑Spring 容器里切换一个 Bean 声明对外暴露的 /image/{id} 地址不变前端代码一行不用改。这个抽象是这类项目最值得带走的设计思路也是你以后做真实系统时不会被“图片到底存哪”这个问题绑死的保障。抽象做好之后建议养成两个验证习惯。第一用 curl -I 检查图片接口的响应头确认 Content-Type、Content-Length、Cache-Control 三个值都符合预期这一步能把回显问题从“玄学”变成可定量的指标。第二上传几张不同格式、不同大小的图片直接在数据库里对比 volume看 LONGBLOB 方案和 Base64 方案的体积差异这个数据能帮你以后在两种方案里做决策而不是凭感觉选。我自己的习惯是把这张图片链路的每个环节都留注释和边界说明因为这类项目往往是团队里新人的第一个任务代码即文档。图片进库这个方案不会是大系统的最终答案但把这条链路吃透你也就把 SSM 三层最核心的交互方式吃透了。希望帮到你。本文还有配套的精品资源点击获取