ARTICLE DETAIL

资讯详情

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

MySQL BLOB字段存取图片实战:JDBC实现图片保存与读取

MySQL BLOB字段存取图片实战:JDBC实现图片保存与读取 1. 方案选型与技术背景为什么你的图片“存”进MySQL那么难做后端开发的兄弟十有八九都遇到过图片存储的需求用户头像、商品图、文章封面、身份证件扫描件一堆文件要落到库里。最常见的错误思路就是把图片路径当作字符串存进MySQL的varchar字段里然后文件本体丢到服务器某个目录下图省事。这种方案短期内看着没问题但后面一旦涉及数据迁移、多节点部署、备份恢复路径一断图片全挂哭都来不及。另一个思路就是今天要聊的正题——把图片的二进制数据直接存进MySQL的BLOB类型字段里。标题里说的“保存和取出”本质就是JDBCJava这边的标准数据库连接方式对BLOB字段的写入与读取。这个方案最大的好处是数据和业务记录永远在一起备份一份SQL文件就等于把所有的图片也备份了不会出现库里引用了文件、文件却被误删的尴尬局面。适合什么场景呢我个人认为小规模应用、内网管理系统、个人项目、快速原型BLOB方案完全够用。单张图片在几百KB到几MB之间一天也就几千张的写入量MySQL扛得住。但如果你做的是短视频平台那种每天千万级图片请求的架构这边建议直接上对象存储或分布式文件系统MySQL存路径和元数据就好。这是后话本文不展开。在动手之前先统一一下技术栈我用的是MySQL 8.0 JDBCmysql-connector-java 8.x Java 11这套组合IDE用的IDEA数据库管理工具Navicat或MySQL Workbench都行。你在实操时版本不要差太多尤其是驱动包和MySQL版本要对应不然连接都建立不起来。这里先给一个总览后面每一行代码都会有注释保证你能看懂每一步到底在干什么。2. 环境准备与数据库设计建表这一步就决定了后面顺不顺2.1 建库建表BLOB类型到底选哪个MySQL里的二进制大对象类型一共有四个TINYBLOB最大255字节、BLOB最大64KB、MEDIUMBLOB最大16MB、LONGBLOB最大4GB。选哪个完全看你的图片大小。一般来说手机拍的照片动辄3~8MB用BLOB肯定放不下最少得MEDIUMBLOB起步。但话说回来真正生产环境中存原始照片的场景极少大部分都是压缩后的缩略图或者WebP、JPEG优化过的图几百KB到1MB左右BLOB也凑合不过我还是建议直接用MEDIUMBLOB省得以后图片尺寸变大再来一次ALTER TABLE迁移那种操作在数据量大时非常痛苦。建表语句我直接放出来注释里说清楚了每个字段的用意-- 创建图片存储表 -- 业务上每一条记录对应一张图片 CREATE TABLE image_store ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 自增主键, biz_type VARCHAR(50) NOT NULL COMMENT 业务类型比如avatar、product、article方便按业务隔离, file_name VARCHAR(255) NOT NULL COMMENT 原始文件名取出时用来还原文件名做下载用, content_type VARCHAR(100) NOT NULL COMMENT MIME类型比如image/jpeg、image/png浏览器靠这个识别图片格式, img_data MEDIUMBLOB NOT NULL COMMENT 图片二进制数据, file_size INT UNSIGNED NOT NULL COMMENT 文件大小单位字节可以用来校验数据完整性, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 入库时间, PRIMARY KEY (id), KEY idx_biz_type (biz_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT图片二进制存储表;几个细节说一下。第一content_type这个字段很多人忽略但它在取出展示的时候至关重要。你从数据库拿到的是没有后缀的字节流怎么知道它是JPEG还是PNG就得靠这个MIME类型。如果漏了前端或者浏览器就没法准确渲染。第二file_name不能省。虽然数据库不依赖文件名来识别图片但你想做“点击下载原图”这种功能时HTTP响应头里的Content-Disposition字段需要文件名从库里取原始文件名最可靠。第三字符集为什么用utf8mb4因为file_name里可能存中文文件名比如“头像.png”utf8mb4兼容性最好不会出乱码。2.2 调整MySQL最大允许包大小参数BLOB字段写入一个2MB的图片本质上就是往MySQL发送一个2MB的SQL语句参数包。MySQL默认的max_allowed_packet是64MB8.0版本默认值正常情况下够用。但如果你用的是老版本或者某些云厂商默认配置很低4MB、1MB都有见过插入稍大一点的图就会报Packet for query is too large错误。这个坑很隐蔽我第一次踩到的时候还以为是驱动配置出了问题查了半天。提前检查一下你的MySQL配置-- 查看当前的参数值 SHOW VARIABLES LIKE max_allowed_packet;如果小于16MB建议改一下。在my.cnf或my.ini的[mysqld]段下加一行[mysqld] max_allowed_packet64M改完重启MySQL服务生效。注意这个参数是数据库服务端的限制客户端也要对应设置不过JDBC驱动默认会继承服务端参数一般不用额外处理。2.3 引入JDBC驱动依赖Maven项目直接在pom.xml里加这一段版本号建议和你本地装的MySQL版本匹配上8.0.x的MySQL用8.0.33这个驱动版本稳得很dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency如果你是非Maven项目手动把驱动jar包扔到项目classpath里也行思路一样不用纠结。3. MySQL保存图片的实现从流到BLOB字段一行一行拆解环境准备好了表也建好了直接上核心代码。我习惯把数据库操作封装成一个工具类避免每次写重复的Connection获取逻辑。这里先给出最简版保证你能跑通之后再按工程化标准优化。3.1 数据库连接工具类import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; /** * 数据库连接工具类 * 纯手写JDBC连接方便理解每一步 */ public class DBUtil { // 驱动类全限定名8.0版本的驱动类名是com.mysql.cj.jdbc.Driver private static final String DRIVER com.mysql.cj.jdbc.Driver; // 连接地址jdbc:mysql://主机:端口/数据库名 // useSSLfalse 本地开发不需要SSL加密 // characterEncodingutf8 保证中文不乱码 // serverTimezoneAsia/Shanghai 指定时区8.0版本必须配置否则报错 // allowPublicKeyRetrievaltrue 允许客户端向服务器请求公钥8.0驱动在非SSL连接下必须设这个否则报Public Key Retrieval错误 private static final String URL jdbc:mysql://localhost:3306/test_db?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这个工具类看着简单但URL里的几个参数是从坑里爬出来的经验。serverTimezone不加驱动会报The server time zone value йʱ is unrecognized这个错乱码一看就是时区识别不了allowPublicKeyRetrievaltrue不加8.0版本在非SSL连接下会直接报错连不上。这些参数一次配好能省掉后面一大堆排查时间。3.2 图片保存核心逻辑PreparedStatement setBinaryStream保存图片的核心思路就一句话把图片文件转成输入流再通过PreparedStatement.setBinaryStream()方法绑定到这个输入流最后执行executeUpdate()。但是千万不能用Statement拼接SQL——SQL注入风险是一方面另一方面二进制数据里可能包含特殊字符拼SQL大概率直接报语法错误。请看完整代码import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.sql.Connection; import java.sql.PreparedStatement; /** * 图片保存实现类 */ public class ImageSaver { /** * 保存图片的入口方法 * * param bizType 业务类型比如 avatar 或 product * param imageFile 图片文件 */ public void saveImage(String bizType, File imageFile) { // try-with-resources自动关闭连接、声明防止连接泄漏 // 注意InputStream不能放在这个try里提前关因为PreparedStatement执行时可能还在读取流 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( INSERT INTO image_store (biz_type, file_name, content_type, img_data, file_size) VALUES (?, ?, ?, ?, ?) )) { // 1. 设置业务类型 ps.setString(1, bizType); // 2. 设置文件名getName()只取文件名不包含路径 ps.setString(2, imageFile.getName()); // 3. 根据文件后缀推断MIME类型这里写一个简单的映射实际项目中可以抽成工具方法 String contentType guessContentType(imageFile.getName()); ps.setString(3, contentType); // 4. 这一步是核心中的核心 // 通过FileInputStream打开文件然后把输入流传给setBinaryStream() // 第二个参数传InputStream第三个参数传文件大小 // MySQL驱动内部会调用InputStream.read()方法把数据写入BLOB字段 // 这里不要用ps.setBlob()setBlob的性能和兼容性不如setBinaryStream try (FileInputStream fis new FileInputStream(imageFile)) { ps.setBinaryStream(4, fis, imageFile.length()); } // 5. 设置文件大小单位字节方便以后做数据校验 ps.setLong(5, imageFile.length()); // 6. 执行插入返回受影响的行数 int rows ps.executeUpdate(); System.out.println(成功插入 rows 行图片大小 imageFile.length() 字节); } catch (Exception e) { e.printStackTrace(); } } /** * 根据文件名后缀推断MIME类型 * 实际项目中建议用Apache Tika这样的组件这里演示用最简单的方式 */ private String guessContentType(String fileName) { if (fileName.endsWith(.png)) { return image/png; } else if (fileName.endsWith(.jpg) || fileName.endsWith(.jpeg)) { return image/jpeg; } else if (fileName.endsWith(.gif)) { return image/gif; } else if (fileName.endsWith(.webp)) { return image/webp; } else { return application/octet-stream; } } // 测试入口 public static void main(String[] args) { ImageSaver saver new ImageSaver(); saver.saveImage(avatar, new File(C:/Users/Admin/Pictures/head.png)); } }这里解释一下为什么用setBinaryStream而不是setBlob。setBlob需要先把整个文件读进内存构建一个Blob对象如果图片是几百MB的大文件一次就内存溢出了。setBinaryStream是流式写入MySQL驱动边读边传内存占用稳定性能反而更好。这也是JDBC规范里推荐的BLOB写入方式。上面代码还有一个细节setBinaryStream(4, fis, imageFile.length())的第三个参数一定得传文件的总字节数。这个值可以在File对象上通过length()获得但要注意如果文件在读取过程中被其他程序修改这个长度和实际长度不一致就会报错。生产环境里要提前做好文件锁定或者先复制到临时目录再上传入库。3.3 保存后如何校验从库里把数据读回来对比保存成功不等于万事大吉建议在插入完成后立刻做一次反向校验。最简单的做法是查询刚插入的记录的file_size和原始文件的字节数比对SELECT id, biz_type, file_name, file_size FROM image_store ORDER BY id DESC LIMIT 1;如果库里存的file_size和本地文件大小一致说明二进制数据完整写入。要做到更严谨的验证可以把两个文件都转成MD5进行比较这里不展开等有时间单独写一篇。4. MySQL取出图片的实现从BLOB字段到浏览器可预览的文件存进去不是目的能完整取出来才是本事。取出图片的核心逻辑跟保存刚好相反把BLOB字段读成InputStream然后写到目标文件或输出到HTTP响应里。下面把两种常见的取出场景——保存成文件和输出到浏览器——都讲一遍。4.1 取出并保存为本地文件getBlob getBinaryStreamimport java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.io.OutputStream; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; /** * 图片读取实现类 */ public class ImageReader { /** * 根据主键ID查询图片并保存到指定路径 * * param id 图片记录的ID * param savePath 保存文件的完整路径例如 C:/download/head.png * return 是否成功 */ public boolean readImageToFile(int id, String savePath) { // SQL只查询需要的字段不要查file_size之外无用的列减少网络传输 String sql SELECT file_name, content_type, img_data FROM image_store WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // 绑定查询参数 ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { System.out.println(未找到ID id 的图片记录); return false; } // 从结果集里取出原始文件名和MIME类型打印出来方便核对 String fileName rs.getString(file_name); String contentType rs.getString(content_type); System.out.println(文件名 fileName 类型 contentType); // 核心获取Blob对象然后调用getBinaryStream()拿到输入流 // Blob.getBinaryStream()返回的是InputStream可以边读边写不会一次性加载到内存 try (InputStream is rs.getBlob(img_data).getBinaryStream(); OutputStream os new FileOutputStream(savePath)) { // 缓冲区大小设成8192字节8KB太小性能差太大浪费内存 byte[] buffer new byte[8192]; int length; long totalBytes 0; // 循环读取输入流写入输出流 while ((length is.read(buffer)) ! -1) { os.write(buffer, 0, length); totalBytes length; } os.flush(); System.out.println(文件保存成功总字节数 totalBytes); } } return true; } catch (Exception e) { e.printStackTrace(); return false; } } // 测试入口 public static void main(String[] args) { ImageReader reader new ImageReader(); reader.readImageToFile(1, C:/download/head_copy.png); } }读出来之后直接拿本地文件管理器打开看看如果能正常显示且清晰度跟原图一致说明二进制数据没有丢。这个操作我建议每次“存-取”流程做完都验证一遍肉眼可见比写一堆单元测试管用。4.2 取出并直接响应到浏览器以Spring Boot接口为例很多时候业务场景不是下载到本地而是在网页上直接显示用户头像、商品图片。这时不需要把数据落盘直接往HTTP响应里扔字节流即可。用Spring Boot写一个接口一行核心代码都不多import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import java.io.InputStream; import java.io.OutputStream; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; /** * 图片展示接口 */ RestController public class ImageController { /** * 根据图片ID输出到浏览器 * * param id 图片ID * param response HTTP响应对象 */ GetMapping(/image/{id}) public void getImage(PathVariable int id, HttpServletResponse response) { String sql SELECT content_type, img_data FROM image_store WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } // 设置响应的Content-Type浏览器根据这个值来渲染 String contentType rs.getString(content_type); response.setContentType(contentType); // 设置缓存有效期为1小时减少重复查询数据库的频率 response.setHeader(Cache-Control, max-age3600); // 把BLOB的数据流直接复制到response的输出流 try (InputStream is rs.getBlob(img_data).getBinaryStream(); OutputStream os response.getOutputStream()) { byte[] buffer new byte[8192]; int length; while ((length is.read(buffer)) ! -1) { os.write(buffer, 0, length); } os.flush(); } } } catch (Exception e) { e.printStackTrace(); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } }用浏览器访问http://localhost:8080/image/1如果一切正常浏览器直接显示出图片。这里建议你用浏览器的开发者工具看一眼网络请求Response Headers里的Content-Type是不是你入库时设置的image/png。如果这里设置错了浏览器会直接下载一个没有扩展名的文件而不是显示图片这个问题和数据库无关纯粹是响应头设置的问题排查路径别跑偏。4.3 输出图片时给文件名动态加后缀Content-Disposition用法如果要做“点击下载原图”的功能光靠Content-Type还不够需要额外设置Content-Disposition响应头// 注意文件名如果包含中文需要用URLEncoder转一下否则浏览器会乱码 String fileName rs.getString(file_name); response.setHeader(Content-Disposition, attachment; filename\ URLEncoder.encode(fileName, UTF-8) \);这样浏览器收到响应后会触发下载行为而不是直接预览。这个需求最常见的场景就是电商后台的“营业资质下载”、管理系统的“导出的报表图片”等等你需要判断清楚业务到底需要预览还是下载接口写法差一行Header用户体验完全不同。5. 实操过程实录一次完整的“存入-取出-验证”全流程理论基础讲完了下面演示一次完整的实操过程。假设我要把本机一张名为demo_avatar.png的图片实际大小约1.2MB存进数据库再从库里读出来保存到另一个目录做对照。5.1 第一步准备测试图片与目标目录在C:/pic_test/目录下放一张测试图demo_avatar.png在该目录下再建一个output子目录专门放从库里导出的文件。源文件目录和输出目录分开避免读出的文件覆盖原文件造成“看起来一样实际根本没从库里读出来”的假象。这个细节亏我吃过一开始把导出的文件直接放在源文件同目录运行完对比时间戳才发现文件根本没被覆盖。5.2 第二步执行保存观察控制台输出运行ImageSaver类的main方法控制台输出如下成功插入 1 行图片大小1258291 字节注意这个数字1258291字节约等于1.2MB和文件属性里显示的大小完全一致说明FileInputStream正确读到了文件的全部内容。接着用Navicat查一下这张表可以看到img_data字段显示的是BLOB类型点击这个单元格工具会提示“二进制数据是否以十六进制形式查看”。不用纠结十六进制内容只看file_size字段是否等于1258291即可。5.3 第三步执行读取对比文件完整性运行ImageReader类把读取结果保存到C:/pic_test/output/demo_avatar_copy.png控制台输出文件名demo_avatar.png类型image/png 文件保存成功总字节数1258291看到总字节数和保存时完全一致基本可以放心。之后打开两张图放在一起肉眼对比色彩、尺寸、透明度完全一致。如果图片有损多半是你在浏览器里手动另存过、或者某一步代码用了字符流转二进制这种“图片能打开但模糊/花屏”的情况十有八九是字符集转换导致二进制数据被破坏了。密码学角度画的图都不带这么花的。5.4 第四步用SQL层面直接查询验证二进制数据长度最后一步纯SQL验证写一个查询语句确认数据确实在库里SELECT id, file_name, content_type, file_size, -- LENGTH()函数返回字段存储的字节数等价于图片的实际大小 LENGTH(img_data) AS blob_len, create_time FROM image_store;如果blob_len等于file_size说明BLOB字段完整存储了二进制内容流程闭环。到这里MySQL图片保存和取出的主流程就已经完全跑通了。6. 常见问题与排查技巧实录值得写进你笔记里的那几个坑6.1 插入时报错Packet for query is too large前面提过这是max_allowed_packet参数太小导致的。解决方案就是修改MySQL配置文件后重启服务。如果你用的是云数据库可能没有改配置的权限那就只能在应用层面做图片压缩把图片压到1MB以内再入库同时把BLOB列改成MEDIUMBLOB之间做好预估。运维层面的路走不通就得靠开发层面妥协这是我在线上环境被迫学会的。6.2 读取时中文文件名变乱码出现这种问题的原因几乎都是数据库连接URL里没有加characterEncodingutf8。很多初学者只会在数据库设置字符集却忽略了驱动连接URL也需要显式指定。解决办法jdbc:mysql://localhost:3306/test_db?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/Shanghai同时确保建表时用了utf8mb4双管齐下基本不会乱码。反过来如果你把数据库字符集设成了latin1即使URL里写utf8也白搭因为服务端已经把字符集转掉了。检查顺序先看表字符集再看连接URL。6.3 图片能插入但读取时IO异常或数据截断这通常是setBinaryStream的第三个参数长度传错了。比如一个文件正在被写入程序打开流的时候还没写完导致传入的长度比实际长度小就会出现数据截断。解决思路是写入前先调用File对象的length()并且做好文件不可变处理比如微信上传接口先落盘到临时目录再去读取入库。Java的NIOFiles.copy()也可以把流内容先复制到数据库但和直接传InputStream相比语义上不太一样建议用本文的方案。6.4 从BLOB取出来生成的图片提示“文件损坏或已损坏”这个问题最常见的原因是写入和读取的编码方式不对称。一个典型错误写入时用setString()把二进制数据当作字符串处理读取时用getString()。MySQL驱动内部会做字符集转换二进制被强行按UTF-8解码再编码图片直接废掉。记住一条铁律二进制数据只能通过BLOB字段和InputStream读写永远不要用字符串的方式处理图片。同理在SQL层面查看时不要用SELECT img_data直接输出到文本终端那不是给人看的而是要用LENGTH()等函数查看长度。6.5 性能提示大批量图片存取的正确姿势批量保存图片时避免一条一条插入用JDBC的addBatch()和executeBatch()批处理功能性能提升非常明显。我实测过一个内网管理系统500张图片逐条插入耗时约47秒改成批量后直接降到9秒。批量插入的代码如下// 假设有一批File对象 File[] files ...; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( INSERT INTO image_store (biz_type, file_name, content_type, img_data, file_size) VALUES (?, ?, ?, ?, ?))) { for (File imageFile : files) { try (FileInputStream fis new FileInputStream(imageFile)) { ps.setString(1, batch_test); ps.setString(2, imageFile.getName()); ps.setString(3, guessContentType(imageFile.getName())); ps.setBinaryStream(4, fis, imageFile.length()); ps.setLong(5, imageFile.length()); ps.addBatch(); } } // 一次性批量执行 int[] results ps.executeBatch(); System.out.println(批量插入完成共执行 results.length 条); }批量插入的核心是每次循环里通过addBatch()把参数缓存起来最后统一executeBatch()。但注意一点如果某些图片特别大超过10MB批处理时内存占用会比较高需要控制每批的数量比如每500条提交一次防止内存溢出。测试环境可以先放开跑生产环境务必做压测。6.6 补充方案Base64与文件路径什么时候该选它们说完BLOB补充两个经常在社区讨论里见到的方案。Base64方案是通过把图片二进制编码成Base64文本再存入TEXT字段这方案的优点是数据可以直接嵌入JSON返回给前端图片在接口响应里是一长串以data:image/png;base64,开头的字符串前端直接放到img的src属性里就能显示。但缺点也很明显Base64编码大约让体积膨胀33%存储成本增加DB读取性能也下降不适合大量图片场景。文件路径方案就是把图片存到磁盘或对象存储数据库只存路径字符串。这套方案更适合大规模、图片访问压力极高的场景。BLOB方案和文件路径方案的核心取舍可以用一句话概括你要的是数据一致性和管理便利还是要性能和存储成本在小规模应用里BLOB的便利性碾压一切。7. 结尾的一些个人经验这套BLOB存取方案我前后在不同项目里用过四五次从最初踩乱码的坑到后来成体系地封装成工具类感受最深的一点是图片存储这件事没有银弹只有适不适合当前场景。如果项目生命周期短、并发低、跟你合作的运维也没有专门的图床方案MySQL BLOB就是最短路径。一旦项目有成长为大型系统的可能建议尽早抽象好存储接口把“存哪”“怎么存”从业务代码里剥离开方便以后无缝迁移到对象存储。再分享一个小技巧存取图片时最好都把biz_type带上同一个表里可以同时存头像、商品图、文章封面取出时按类型加一层目录隔离无论是调试还是人工排查数据都很方便。很多初学者喜欢给每种图片建一张表完全没必要加个类型字段就搞定了。最后提醒一句BLOB字段不要放到频繁更新的表上InnoDB对大字段的处理是行外存储频繁Update会把碎片放大影响查询性能。如果业务上既有图片又有大量变更的文本字段建议拆成两张表通过外键关联。这一点很少有人提但生产环境遇到性能瓶颈时你会感谢这个建议。
返回列表