简介:面向Java Web初学者或毕业设计开发者,提供一套基于SSM(Spring+SpringMvc+Mybatis)的图片上传、数据库保存与页面回显完整项目案例,解决文件上传后信息持久化与前端展示的常见需求。zip压缩包共120个文件,大小约17.5MB,包含53个jar依赖库、18个xml配置与映射文件、13个java源码、13个class编译文件、11个jsp页面以及sql数据库脚本,覆盖项目依赖、数据表结构、业务控制器、映射配置和展示页面等层次,整体结构清晰,便于按模块阅读。目前已有5469人浏览学习,是SSM文件上传类别中较受欢迎的实战参考。案例完整演示了文件接收、服务器存储与唯一命名、通过Mybatis将图片信息写入数据库、在前端用img标签回显等关键步骤,同时附带用户登录和Shiro权限相关类,便于理解权限控制与真实项目组织方式,代码配有注释,适合课设、毕设或工作初期的功能扩展。 做SSM(Spring+SpringMvc+Mybatis)开发时,图片上传这块我见过太多人纠结了。论坛里一搜,十个人有九个会劝你"别把图片存数据库,存服务器磁盘路径就完事了"。这话对了一半——如果你的图片量大、访问并发高,确实该走文件服务或对象存储;但如果你的项目是内部管理系统、数据量不大、又希望所有业务数据跟着数据库一起备份、迁移、保证一致性,那"图片入库"反而是最省事的选择。这篇文章我就把SSM框架下图片上传、保存到数据库、再到前端回显的全链路拆开讲清楚,附上可直接用的SQL和Java代码,把我实际踩过的坑一并交代了。
先说明一下,这里的"保存到数据库"有两种主流实现:一种是直接把图片转成Base64字符串存TEXT字段,另一种是以二进制byte[]形式存BLOB字段。两种我都用过,也都会讲到,但会明确告诉你什么场景选哪个,避免你走了弯路还找不到原因。
1. 图片入库的三种存储姿势:路径、Base64与BLOB怎么选
1.1 先看清"存路径"和"存数据库"的本质区别
最经典的方案确实是"图片存磁盘/服务器,数据库只存访问路径"。用户上传图片后,后端用UUID重命名文件,写入服务器某个目录(比如/upload/images/),然后把/upload/images/2024/xxx.jpg这个字符串存进数据库的VARCHAR字段。前端img标签直接拿这个路径访问就行。
但这个方案有一个隐含前提:图片文件和数据库记录必须同时在、同时可用。如果你把数据库导出到另一台机器,图片文件却落在原来的服务器上,那回显必然裂开。内网OA、小规模进销存、课程设计这类项目,维护成本恰恰出在这里。数据库还能一键导出成SQL,服务器上的图片目录反而经常被忽略。
所以当你的需求里明确写了"保存到数据库"(比如课程设计、毕业设计、小型管理系统),别怀疑,直接走入库路线,评审的老师看到表里有BLOB或LONGTEXT字段负责存图,反而觉得你的设计是有意为之。
1.2 Base64存TEXT与byte[]存BLOB的对比
把图片转成Base64字符串存库,优点是存进去的内容肉眼可见,用Navicat打开表能直接看到一长串文本,排查问题非常直观。缺点是Base64会让数据体积膨胀约33%,一张1MB的图片存进数据库变成1.33MB左右。
把图片以二进制byte[]存BLOB字段,体积不膨胀,更接近"数据库就该这么存文件"的预期。但缺点是工具里打开BLOB字段只能看到"BLOB"几个字,调试时还得靠程序去读,心里会有点没底。
我给你的选型建议是这样的:
| 对比维度 | Base64存TEXT | byte[]存BLOB |
|---|---|---|
| 存储体积 | 膨胀约1/3 | 原始大小 |
| 可读性 | 可直接查看 | 工具中不可读 |
| 适合图片大小 | 推荐100KB以下(头像、缩略图) | 可存数MB的大图 |
| 开发调试体验 | 较好 | 需要写代码验证 |
| SQL导出后可用性 | 高 | 高 |
1.3 我最终采用的组合方案
我在实际项目里常用的是一个折中设计:图片二进制存BLOB,同时冗余一个Base64字段用于快速回显,或者干脆在上传接口里直接把byte[]转成Base64返回给前端。但这要求图片本身不能太大,如果一张图好几MB,每次查询都做Base64转换会显著拖慢接口响应。
所以如果你问我现在做SSM项目会怎么选,我给一个明确答案:默认存Base64到MEDIUMTEXT或LONGTEXT字段。原因很简单——SSM项目大部分是中小型系统,图片以头像、商品图、单据照片为主,大小可控,Base64处理起来代码最少、报错最少、调试最方便。真要搞大图、大流量,你也不会选SSM这套组合去扛了。
2. 建表SQL与字段类型细节:从MEDIUMBLOB到LONGTEXT
2.1 一张能直接用的图片信息表建表语句
标题里专门提到"sql",说明表结构是大家最关心的部分。下面这张表是我在项目中反复调整后的版本,覆盖了"图片基础信息 + 图片内容 + 回显所需字段"。
-- 图片信息表 CREATE TABLE `t_image` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `biz_type` varchar(50) NOT NULL COMMENT '业务类型:avatar/product/evidence', `biz_id` bigint(20) DEFAULT NULL COMMENT '业务相关的主键ID,比如用户ID', `origin_name` varchar(255) DEFAULT NULL COMMENT '文件原始名称', `file_ext` varchar(20) DEFAULT NULL COMMENT '扩展名:jpg/png/gif', `content_type` varchar(100) DEFAULT NULL COMMENT 'MIME类型:image/jpeg等', `image_base64` longtext COMMENT '图片Base64字符串(回显直出用)', `image_blob` longblob COMMENT '图片二进制内容(存BLOB方案用)', `file_size` bigint(20) DEFAULT NULL COMMENT '文件字节数', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_biz_type_id` (`biz_type`,`biz_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图片信息表';关于字段类型,说实话我第一次设计时踩过坑。当时想用BLOB存图片,结果原图稍微大一点,插入就报错"Data too long for column"。后来才彻底搞明白MySQL里几个类型肉眼区分度不高,实际上限差距却很大:
| 类型 | 最大长度 | 说明 |
|---|---|---|
| TINYBLOB / TINYTEXT | 255字节 | 只能存极小文本或缩略图 |
| BLOB / TEXT | 64KB | 几十KB的头像勉强够用 |
| MEDIUMBLOB / MEDIUMTEXT | 16MB | 适合大多数普通图片 |
| LONGBLOB / LONGTEXT | 4GB | 几乎不用担心超限 |
建表我直接给你LONGBLOB和LONGTEXT,因为字段类型的容量差距不会让数据库变慢到哪去,但一旦图片超限报错,排查成本远高于那一点存储资源的浪费。
2.2 为什么把Base64和BLOB都放同一张表
有些读者可能会问,既然选了Base64方案,为什么表里还留一个image_blob字段?这是为了兼容两种回显方式——小图走Base64直出最快,如果以后图片变大改走二进制流接口,不需要新增字段,同一个字段就能用上。
另外我习惯给biz_type和biz_id加联合索引。虽然单看表数据量不大,但一旦图片表积累了上万条记录,又没有索引,每次按业务ID查询都会全表扫描。加了索引后SQL优化器才能走ref或者range,回显接口的查询耗时能差一个数量级。
2.3 关于SQL导出和迁移的提醒
选Base64方案有个额外好处:你用Navicat导出这张表的时候,图片内容会直接以文本形式出现在SQL文件里,整个数据库的备份、迁移、导入变得非常无脑。BLOB字段在SQL导出时则会变成一段十六进制或转义二进制,虽然也能导入,但在别的工具里可读性差。
假如你的项目需要"把整库发给别人,别人导入就能跑",Base64方案绝对是首选。我自己做过一个课程设计,把SQL文件发给同学,对方导入后所有图片都正常显示,整个过程没有任何文件目录要拷贝,这种省心程度是"存路径"方案给不了的。
3. 上传链路搭建:MultipartFile到Mapper的完整代码
3.1 环境准备:Maven依赖与SpringMVC上传解析配置
先把依赖加齐。基于Maven的SSM项目,pom.xml里至少要有这些东西:
<!-- Spring Web MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.30</version> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.1</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Commons FileUpload --> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.5</version> </dependency> <!-- 常用工具和JSON --> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.11.0</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency>SpringMVC要处理multipart/form-data请求,核心是配置一个MultipartResolver。很多初学者漏配这一步,结果前端明明传了文件,后端MultipartFile参数却一直是null,浪费大量时间。我用的配置如下:
<!-- spring-mvc.xml --> <bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <!-- 单次请求最大字节数,这里设置10MB --> <property name="maxUploadSize" value="10485760"/> <!-- 单个文件最大字节数,这里设置5MB --> <property name="maxUploadSizePerFile" value="5242880"/> <!-- 默认编码 --> <property name="defaultEncoding" value="UTF-8"/> </bean>这里有个容易被忽略的点:CommonsMultipartResolver的maxUploadSize是指整个请求的body大小,不是单文件大小。如果你一次传多个文件,maxUploadSize得比所有文件总和再留一点余量。否则后来你发现单文件5MB以内能传,两个文件加起来超了10MB就报错,还不容易联想到是这个配置的问题。
3.2 Controller层:接收文件、参数校验、返回统一JSON
Controller是上传链路的第一道关口,核心任务是:接收MultipartFile、校验文件是否为空、判断图片类型和后缀、调用Service保存、把结果封装成前端友好的JSON。
@RestController @RequestMapping("/image") public class ImageController { @Autowired private ImageService imageService; @PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file, @RequestParam(value = "bizType", defaultValue = "common") String bizType, @RequestParam(value = "bizId", required = false) Long bizId) { if (file == null || file.isEmpty()) { return Result.error("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = ""; if (originalFilename != null && originalFilename.contains(".")) { ext = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); } if (!"jpg".equals(ext) && !"jpeg".equals(ext) && !"png".equals(ext) && !"gif".equals(ext)) { return Result.error("仅支持jpg/jpeg/png/gif格式的图片"); } if (file.getSize() > 5 * 1024 * 1024) { return Result.error("图片大小不能超过5MB"); } ImageInfo imageInfo = imageService.saveImage(file, bizType, bizId, ext); return Result.success(imageInfo); } }注意我这里同时做了两层校验:扩展名校验和大小校验。这样做不是为了炫技,而是因为前端可以做校验,但绕过前端直接调用接口的人不会管你的页面规则,后端防御是唯一可靠的防线。
3.3 Service层:文件转Base64和byte[],落库
Service层包含本次的核心逻辑:读取文件字节、转Base64、封装实体、调用Mapper插入。我用Apache Commons IO的IOUtils来读写流,简洁且不容易出错。
@Service public class ImageServiceImpl implements ImageService { @Autowired private ImageMapper imageMapper; @Override @Transactional(rollbackFor = Exception.class) public ImageInfo saveImage(MultipartFile file, String bizType, Long bizId, String ext) throws IOException { byte[] bytes = file.getBytes(); String base64Str = Base64.getEncoder().encodeToString(bytes); ImageInfo imageInfo = new ImageInfo(); imageInfo.setBizType(bizType); imageInfo.setBizId(bizId); imageInfo.setOriginName(file.getOriginalFilename()); imageInfo.setFileExt(ext); imageInfo.setContentType(file.getContentType()); imageInfo.setImageBase64(base64Str); imageInfo.setImageBlob(bytes); imageInfo.setFileSize((long) bytes.length); imageMapper.insert(imageInfo); return imageInfo; } }这里有一个关键细节:Base64.getEncoder().encodeToString(bytes)出来的字符串是不带换行符的,这正好是前端img标签src属性需要的格式。如果你用MIME类型的encoder(Base64.getMimeEncoder()),会在超过76个字符处插入换行符,那种字符串直接放进data:image/jpeg;base64,xxx里极大概率导致图片显示不出来,而且肉眼几乎看不出问题在哪。这个坑我踩过一次之后就再也没用过MimeEncoder。
事务方面,我给saveImage加了@Transactional。原因是如果后续要扩展"保存图片信息同时更新业务表"这类操作,两条SQL必须同生共死,一旦第二条失败第一条已经插入,数据就脏了。
3.4 Mapper层:插入语句的写法与jdbcType
Mybatis的Mapper层要注意insert语句中#{}的使用。有人会把BLOB字段的插入写成这样然后报错:
<insert id="insert" parameterType="com.example.entity.ImageInfo" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_image ( biz_type, biz_id, origin_name, file_ext, content_type, image_base64, image_blob, file_size ) VALUES ( #{bizType}, #{bizId}, #{originName}, #{fileExt}, #{contentType}, #{imageBase64}, #{imageBlob}, #{fileSize} ) </insert>这个写法本身没错,但如果你在Mapper层直接用byte[]映射BLOB字段,某些版本组合下会报"Error setting null for parameter #6 with JdbcType OTHER"。稳妥的写法是显式指定jdbcType:
<insert id="insert" parameterType="com.example.entity.ImageInfo" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_image ( biz_type, biz_id, origin_name, file_ext, content_type, image_base64, image_blob, file_size ) VALUES ( #{bizType}, #{bizId}, #{originName}, #{fileExt}, #{contentType}, #{imageBase64, jdbcType=LONGVARCHAR}, #{imageBlob, jdbcType=BLOB}, #{fileSize} ) </insert>jdbcType的作用是告诉Mybatis数据库端对应的JDBC类型,避免它在类型推断时出幺蛾子。LONGVARCHAR对应TEXT/LONGTEXT类大文本,BLOB对应BLOB/LONGBLOB类二进制,这套映射在MySQL上我已经验证过很多次。
4. 回显方案:Base64直出接口与二进制流图片接口的取舍
4.1 方式一:查库拿到Base64,拼data URI直接回显
这是最简单、也最适合SSM小项目的回显方式。图片入库后,Controller提供查询接口,Service从数据库把image_base64取出来,前端img标签直接把data:image/jpeg;base64,xxx塞给src属性。
Controller层的查询接口:
@GetMapping("/get/{id}") public Result getImage(@PathVariable("id") Long id) { ImageInfo imageInfo = imageService.getImageById(id); if (imageInfo == null) { return Result.error("图片不存在"); } return Result.success(imageInfo); }前端拿到JSON后,拼一个data:URI。以jQuery为例:
$.get('/image/get/' + imageId, function (res) { if (res.code === 0) { var img = document.getElementById('previewImg'); img.src = 'data:' + res.data.contentType + ';base64,' + res.data.imageBase64; } });这个方案的好处是:查询接口返回的字段包含了contentType,前端能根据MIME类型拼接出准确的data前缀,不用写死image/jpeg。万一你存的是PNG或GIF,写死类型会直接导致浏览器无法渲染。
坏处也很明显:如果图片本身超过几百KB,Base64字符串会非常长,前端把整个JSON解析出来再塞给img标签,页面渲染会有一帧延迟,而且浏览器地址栏和网络面板会看到一长串Base64,初看不太美观。但在数据量小、图片尺寸可控的场景,这个缺点完全可以接受。
4.2 方式二:后端动态输出二进制流
如果图片体积较大,或者你想让img标签的src看起来像一个正常URL,那就不要返回Base64,直接在Controller里把BLOB二进制写回响应流。
@GetMapping("/show/{id}") public void showImage(@PathVariable("id") Long id, HttpServletResponse response) throws IOException { ImageInfo imageInfo = imageService.getImageById(id); if (imageInfo == null) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } response.setContentType(imageInfo.getContentType()); // 禁止浏览器缓存或设置合适的缓存,按需选择 response.setHeader("Cache-Control", "max-age=86400"); ServletOutputStream outputStream = response.getOutputStream(); outputStream.write(imageInfo.getImageBlob()); outputStream.flush(); outputStream.close(); }前端就极其正常了:
<img src="/image/show/1" />这种方式对前端页面来说最友好,因为img标签发起的请求就是一次普通的GET,浏览器会像处理一个静态图片文件一样处理它。缺点是多了一次HTTP请求,而且后端每渲染一张图就要查一次数据库,如果页面上有几十张图,查询压力会明显上升。
4.3 两种回显的选型结论
我在实际项目中总结出的一个规律是:单图回显(头像、用户签名图、封面图)优先走Base64直出,因为前端拿JSON时一并拿到所有信息,少一次请求;批量列表回显(商品列表、相册列表)优先走二进制流接口,页面上一堆img /image/show/id各发各的请求,浏览器还能并发加载,比一次性返回几十个Base64大字符串更流畅。
如果你嫌两种方式维护成本高,还有一个折中做法:表里保留image_blob和image_base64两个字段,查询列表接口只返回Base64,详情弹窗用二进制流接口。反正字段都在同一张表里,代码写清楚注释就行。
5. 跑通后防翻车:大小限制、BLOB截断、回显乱码等六个高频坑
5.1 Nginx和Tomcat的双重请求体限制
上传大图报错413 Request Entity Too Large,大概率不是你的代码问题,而是Nginx默认允许的请求体太小。Nginx默认client_max_body_size为1MB,你上传一张超过1MB的图片就直接被拦截。解决方式是在Nginx配置文件对应的server或location中加上:
client_max_body_size 10m;加完之后记得nginx -s reload。这里有一个很多人忽略的层级问题:如果你用了Nginx反向代理,Nginx的限制会先于Tomcat生效。就算你把SpringMVC的maxUploadSize调到100MB,只要Nginx还卡在1MB,大图一样传不上去。
除了Nginx,Tomcat 8.5之后对请求体也有默认限制。如果你在Tomcat的server.xml配过Connector,建议确认是否设置了maxPostSize。它的默认值是2MB,也就是说POST请求体超过2MB会被Tomcat拒绝。解决方案是把maxPostSize设为 -1(表示不限制):
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxPostSize="-1" />5.2 MySQL的max_allowed_packet限制
Data too long for column并不总是字段类型太小,也可能是MySQL服务端参数max_allowed_packet的限制。这个参数控制MySQL服务器接收的数据包大小,默认值是4MB或64MB(取决于版本)。当你往LONGBLOB字段插入一张超过该值的大图时,MySQL会直接报错。
可以通过下面这条SQL查看:
SHOW VARIABLES LIKE 'max_allowed_packet';如果值太小,可以在MySQL配置文件my.cnf/my.ini的[mysqld]段中调大:
[mysqld] max_allowed_packet = 64M改完之后重启MySQL服务,再回来插入大图,问题就消失了。这是我在处理"基础资料里插入附件图片"时踩过最隐蔽的坑,因为错误信息看起来像字段超长,实际是网络包超限。
5.3 图片回显时Content-Type不匹配,出现下载而不是预览
如果你走二进制流回显方式,但把response.setContentType写成了application/octet-stream,浏览器就会弹出下载框而不是直接在页面上显示图片。我见过有人排查了半天页面,结果发现是contentType存到数据库之前就已经不对了。
所以上传时最好把file.getContentType()原样存下来——SpringMVC会根据扩展名自动给出image/jpeg、image/png这类MIME类型,回显时原样返回。如果你在Service里硬编码image/jpeg,一旦上传PNG就会出现乱码或无法显示。
5.4 Base64字符串过长的SQL模式问题
如果你用的是longtext字段存Base64,还要注意MySQL的sql_mode是否包含STRICT_TRANS_TABLES。在严格模式下,插入超长字符串会直接报错;在非严格模式下,MySQL会把内容截断,并且只给一个警告。截断后的Base64无法完整解码,前端图片就是碎的。
排查方式是执行:
SELECT @@sql_mode;如果包含严格模式,那超长就应该报错,这是好事;真正怕的是非严格模式下被静默截断,你看到图片显示不完整还以为是前端问题。
5.5 Druid连接池与BLOB字段的兼容性
项目里用了阿里的Druid连接池的话,如果BLOB字段插入或查询偶尔报奇怪的序列化错误,可以检查一下Druid的stat插件或者connection-properties配置。我自己遇到过一次setBinaryStream相关异常,最后通过升级Druid版本、并确认连接池没有开启非必要的SQL审计插件解决的。如果你用的是默认配置,大概率不会碰到这个问题,但值得留个心眼。
5.6 前端显示完全空白可能是转义问题
最后提一个容易被忽略的前端问题:Base64字符串里如果包含+号、/号,在某些模板引擎(比如Thymeleaf的th:attr)里会被转义,导致拼到src属性后URL被错误解析,图片就显示不出来。解决办法是:前端不要用模板引擎的字符串拼接,直接用JavaScript把Base64赋给img.src,避免HTML转义这一步。
如果后端用JSON返回Base64,还要注意JSON序列化会不会把+转成空格——某些序列化框架对+号处理不严谨。我在项目中用Fastjson序列化没有遇到这问题,但如果你用其他框架,建议上传一张带+号的Base64测试图验证一下。
写在最后
SSM项目里图片上传保存到数据库这条路,整体不算复杂,但细节非常密集。我从建表字段选型、上传链路编写、回显方案对比到Nginx/Tomcat/MySQL三层限制的排查,基本把常见的坑都过了一遍。
个人体会是:这类功能最怕的不是写不出代码,而是"数据库存了图片但显示不出来"的玄学问题。绝大多数情况下,问题都出在Base64字符串格式、Content-Type设置、请求体大小限制这几处。你只要按文中提到的方式逐一排查,基本都能快速定位。如果看到这里你已经准备动手,我建议先把建表SQL执行了,然后用Postman模拟一次上传,再写几个img标签测试回显,链路通了之后再考虑各种边界情况。
本文还有配套的精品资源,点击获取