简介:一份可直接运行的C#示例工程,演示如何把照片以二进制形式保存进MySQL数据库,适合需要实现图片上传、头像存储等功能的.NET开发者,尤其是刚接触二进制字段与ADO.NET的初学者。压缩包共48个文件,包括C#源码、解决方案与工程配置、资源文件、SQL建表脚本、可执行程序及调试文件,整体约333KB,结构清晰,便于直接打开工程对照学习。目前已有2013人浏览学习。示例覆盖从File.ReadAllBytes读取照片、建立MySqlConnection数据库连接,到设计BLOB字段并用MySqlCommand参数化插入图片的完整流程,同时附有SQL脚本,可快速在本地还原测试环境。读者可借此掌握二进制数据存取、MySQL连接管理和防SQL注入写法,并迁移到社交、博客等需处理图片上传的应用场景中。
1. 照片不能只存路径:C# 落库 MySQL 的真正难点在哪
做 C# 上位机或管理类系统时,碰上“拍照存证”是常事:流水线拍工件、门店拍单据、客户端传头像。第一反应往往是图片存硬盘、数据库只留路径。可等你真去归档、定时备份、从测试机挪到生产机时,路径方案就成黑匣子了——文件在 C 盘、记录在数据库,两边一拆就永远对不上。把照片转成字节数组写进 MySQL 的 BLOB 字段,才是这类系统里更稳的落法。这篇笔记把 C# 连 MySQL、参数化写入、读回并显示图片的完整链路拆开,每步给可复现代码,顺带说清连接串、max_allowed_packet、SSL 这几个真正卡人的口。适合刚接触 MySQL 的 C# 程序员,也适合在给自己小系统加图片归档的工程师。
2. 为什么不直接存路径:BLOB 与路径方案的选择与代价
2.1 路径方案的三个“异地”灾难
存路径最简单的写法是:数据库放“D:\uploads\20240601\123.jpg”,文件放到那个目录。单机用着没问题,一进“归档”“备份”“迁移”三个场景就露馅。归档时你拷贝数据库备份,路径指到的 D 盘目录往往没人记着要一起拷;备份软件只盯 MySQL 数据目录,图片目录不在里面,恢复后一大批记录全是破碎链接;迁移到另一台服务器,盘符从 D 变成 E,代码里就得写一堆路径替换逻辑。更别提单反照片动辄 8MB,文件系统上零散分布,杀毒软件一扫描 CPU 直接飘红。
还有一个细节值得留意:很多数据库同步工具默认只同步表结构和行数据,不认你磁盘上的图片目录。如果你的系统要用这类工具做增量同步,路径方案会直接把整个链路打断,而 BLOB 方案跟着记录走,同步工具不需要额外感知图片文件。
把这些照片二进制塞进数据库后,数据文件和记录就同生死了:备份一套文件带走全部,迁移逻辑变成一次 mysqldump,应用层根本不用知道图片原来放在哪个目录。代价是数据库文件会明显变大——但一张工单照片的完整性,往往比那一百多 MB 的磁盘占用值钱。这个取舍在做选型时要跟业务说清楚,免得后期被反问“为什么数据库这么大”。
2.2 选 LONGBLOB 还是 MEDIUMBLOB:照片大小决定字段类型
MySQL 的 BLOB 家族分四档,选错一档,轻则数据插不进去,重则读出来被截断。TINYBLOB 256 字节,BLOB 64KB,MEDIUMBLOB 16MB,LONGBLOB 4GB。手机随手拍的照片,多数 JPEG 在 2MB 到 6MB 之间,MEDIUMBLOB 理论上够用;但一旦客户发来一张 18MB 的 TIF,或者后续想直接存 PDF 扫描件,MEDIUMBLOB 就爆了。
| 类型 | 上限 | 适用场景 |
|---|---|---|
| TINYBLOB | 256 字节 | 小标记、开关状态图 |
| BLOB | 64KB | 小图标、几 KB 的缩略图 |
| MEDIUMBLOB | 16MB | 一般手机照片 |
| LONGBLOB | 4GB | 单反原图、扫描件、混合文档 |
所以我建表时基本直接选 LONGBLOB,让上限根本不成为话题。四种类型的行为完全一致,只是存储上限不同,在 C# 里对应同一个 byte[],代码不用为类型做分支。如果你的业务确定只存几千字节的小图标,BLOB 也够,但存储空间本身不值几个钱,选 LONGBLOB 换未来三年少改一次表结构,划算。
2.3 混合方案:原图进库,缩略图进缓存
只存原图,每次列表页渲染都从 MySQL 读几 MB,数据库压力不小。常见做法是库表里放两个二进制列:一个 LONGBLOB 存原图,一个 MEDIUMBLOB 存缩略图;列表接口只查缩略图,点开详情才拉原图。
我一般会在写入时直接用 GDI+ 生成 200x200 的缩略图,连同原图一起插进一行。这样表和读写逻辑会多一点点复杂度,但换来的体验很实在——列表滚动不卡,MySQL 返回的包也小。本节先只讲单列,后面第 4 章会给出缩略图生成的踩坑点,选型阶段你只需在表设计里留好这个位置。
路径方案也不是完全淘汰。如果照片单张几百 MB,或者要存视频,BLOB 会让 MySQL 的 redo/undo 日志也跟着膨胀,一张表到 100GB 时备份时间从小时级变成天级。这种场景就要回到文件服务器加路径表。我自己心里的分界线是:单文件 10MB 以下、每月新增几千张,适合 BLOB;超出这个量,用对象存储或独立文件服务更实际。
3. 把照片写进 MySQL:连接串、参数化与 BLOB 写入的完整代码
3.1 前置准备:MySQL 建库建表与连接串
开发环境我默认你已装好 MySQL 8.0,如果连实例都还没有,先去官网下载社区版,安装配置时勾选 Developer Default 即可。C# 侧需要装驱动,NuGet 里搜 MySql.Data 或 MySqlConnector,用命令dotnet add package MySql.Data装进项目。
连接串是最先卡人的地方,我项目里常用的最小可跑串长这样:
server=127.0.0.1;port=3306;database=image_db;user=root;password=123456;SslMode=None;CharSet=utf8mb4;Pooling=true;逐项说明:server/port/database/user/password 不用多说;SslMode=None 是本地调试常用的写法,因为 MySQL 8 默认把 SSL 开着,客户端驱动版本不匹配时握手会失败,生产环境如果只走内网且没配证书,也要显式写 Preferred;CharSet=utf8mb4 是为了中文文件名不乱码;Pooling=true 让多张照片连续写入时复用连接,避免每次插入都经历一次 TCP 握手。
建表脚本我习惯放在独立 .sql 文件里,方便在 Navicat 或命令行里直接重放:
CREATE DATABASE IF NOT EXISTS image_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE image_db; CREATE TABLE photo_table ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, photo_name VARCHAR(100) NOT NULL, photo_data LONGBLOB NOT NULL, upload_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:id 用 UNSIGNED,照片量大不容易撞上限;photo_name 用于在业务里展示文件名或单据号,别把它当路径用;upload_time 给后续做归档、定期清理留了个排序字段。如果计划做缩略图,提前加一列 photo_thumb MEDIUMBLOB,这会省一次 ALTER TABLE。注意所有字符集选项都锁在 utf8mb4,这条链路上任何一处用默认 latin1,中文名都可能变乱码。
3.2 插入单张照片:字节数组与 MySqlParameter 的正确姿势
插入照片的核心是File.ReadAllBytes把文件读成byte[],再作为二进制参数发给 MySQL。完整方法:
using MySql.Data.MySqlClient; using System; using System.IO; public int InsertPhoto(string filePath, string photoName) { byte[] imageBytes = File.ReadAllBytes(filePath); string connStr = "server=127.0.0.1;port=3306;database=image_db;user=root;password=123456;SslMode=None;CharSet=utf8mb4;Pooling=true;"; using (var conn = new MySqlConnection(connStr)) { conn.Open(); const string sql = "INSERT INTO photo_table (photo_name, photo_data, upload_time) VALUES (?name, ?data, NOW());"; using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.Add(new MySqlParameter("?name", MySqlDbType.VarChar, 100) { Value = photoName }); cmd.Parameters.Add(new MySqlParameter("?data", MySqlDbType.LongBlob) { Value = imageBytes }); return cmd.ExecuteNonQuery(); } } }逻辑说明:代码分四步——读文件、开连接、声明参数化 INSERT、执行。亮点在两个参数都用了MySqlParameter并显式声明MySqlDbType,而不是AddWithValue一把梭。AddWithValue 会按字符串推断,byte[] 经常被当成 VARCHAR 塞给 MySQL,轻则插不进去,重则把二进制当文本存,读出来直接毁图。
参数说明:?name的 VarChar 长度 100 要和表结构对齐;?data不需要长度,LongBlob 自动适配大对象;NOW()由 MySQL 端写时间,避免应用服务器与数据库服务器时钟偏差。返回值 ExecuteNonQuery 表示影响行数,大于 0 即插入成功。注意 MySql.Data 的参数占位符用?或@都行,关键是参数名必须和 SQL 里的占位符一致。
3.3 批量写入:事务与连接池的配合
拍一组照片的批量上传是更常见的工况。几十张图循环调用上面的 InsertPhoto 会打开几十次连接,速度慢且中途失败没法集体回滚。我一般把循环放进一个事务里,参数对象只建一次,循环内改 Value:
public int InsertPhotoBatch(List<string> filePaths) { string connStr = "server=127.0.0.1;port=3306;database=image_db;user=root;password=123456;SslMode=None;CharSet=utf8mb4;Pooling=true;"; int count = 0; using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { const string sql = "INSERT INTO photo_table (photo_name, photo_data, upload_time) VALUES (?name, ?data, NOW());"; using (var cmd = new MySqlCommand(sql, conn, tx)) { cmd.Parameters.Add(new MySqlParameter("?name", MySqlDbType.VarChar, 100)); cmd.Parameters.Add(new MySqlParameter("?data", MySqlDbType.LongBlob)); for (int i = 0; i < filePaths.Count; i++) { cmd.Parameters[0].Value = Path.GetFileName(filePaths[i]); cmd.Parameters[1].Value = File.ReadAllBytes(filePaths[i]); count += cmd.ExecuteNonQuery(); } } tx.Commit(); } } return count; }逻辑说明:与单张版本相比,事务包住全部 INSERT,任何一张照片读到一半抛异常,tx.Commit()不会执行,连接释放时事务自动回滚,不会留下一半成功一半缺失的脏数据。参数对象在循环外创建,循环里只替换 Value,避免了反复 Dispose 和重新构造的开销。
提示:批量写 50 张 5MB 照片时,MySQL 端要确保
max_allowed_packet配置足够大,否则会报 “Packet for query is too large”。这个问题在第 5 章会专门展开。
3.4 连接串不要硬编码:MySqlConnectionStringBuilder 与配置文件
前面的代码为了短,直接把连接串写死在方法里,真实项目我从不这么干。一是密码和 Server 地址混在业务代码里,后续改库地址要重新编译;二是分号漏一个、大小写错一位,排查起来很费劲。用MySqlConnectionStringBuilder可以消除大部分低级错误:
var builder = new MySqlConnectionStringBuilder { Server = "127.0.0.1", Port = 3306, Database = "image_db", UserID = "root", Password = "123456", SslMode = MySqlSslMode.None, CharacterSet = "utf8mb4", Pooling = true, DefaultCommandTimeout = 30 }; string connStr = builder.ConnectionString;逻辑说明:Builder 把所有连接项变成强类型属性,写错枚举值编译期就报错,生成的 ConnectionString 格式由驱动保证。生产环境我一般把这个连接串放进 appsettings.json,再配合配置中心或环境变量注入,代码里只保留读取逻辑,密码不落盘。
参数说明:DefaultCommandTimeout = 30是给查询一个兜底超时,BLOB 读取在弱网环境容易卡住,没有超时的话上层界面会像死了一样。写入大批量照片时,这个值可以临时调到 60 秒。
4. 从 MySQL 读回照片:字节流还原、显示与 Base64 的边界
4.1 读取照片数据:一次 ExecuteScalar 拿到 byte[]
从库里读单张照片很简单,和普通字符串的增删改查没什么区别,差别只在返回值的类型转换。按 id 查询:
public byte[] GetPhotoData(int id) { string connStr = "server=127.0.0.1;port=3306;database=image_db;user=root;password=123456;SslMode=None;CharSet=utf8mb4;Pooling=true;"; using (var conn = new MySqlConnection(connStr)) { conn.Open(); const string sql = "SELECT photo_data FROM photo_table WHERE id = ?id;"; using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.Add(new MySqlParameter("?id", MySqlDbType.Int32) { Value = id }); object result = cmd.ExecuteScalar(); return result == DBNull.Value ? null : (byte[])result; } } }逻辑说明:ExecuteScalar取第一行第一列,正好对应单条查询场景;拿到的是object,先判断 DBNull 再强转 byte[]。细节点在于用完的 Connection、Command 一定要进 using,BLOB 数据少则几 MB 多则几十 MB,若连接不释放,连接池很快会被大对象拖满。
提示:如果读取大量缩略图,就不该用 ExecuteScalar 一条条来,应该一次 SELECT 多行、走 DataReader 循环读取。当列表页有几十个照片 ID 时,拼 IN 一次查回再按 id 分组,比逐个查快出一个数量级。
4.2 还原成 Image:MemoryStream 与 Position 的细节
byte[] 到手之后,把它喂给 Image.FromStream 是一行代码的事,但 80% 的“读出来图片损坏”都出在流的位置没归零。
using System.Drawing; using System.IO; public Image LoadImage(int id) { byte[] data = GetPhotoData(id); if (data == null || data.Length == 0) return null; using (var ms = new MemoryStream(data)) { Image img = Image.FromStream(ms); return new Bitmap(img); } }逻辑说明:MemoryStream 创建后 Position 是 0,Image.FromStream 从当前位置读到流尾,GDI+ 会把整张图解析出来。问题在于返回的 Image 内部引用这个流,一旦 using 块把 ms 释放,之后 PictureBox 的 Image 再刷新就可能报“参数无效”。所以这里特意new Bitmap(img)复制一份,让返回对象不依赖流生命周期;原 img 在方法结束后自然失去引用,交给 GC 回收。
参数说明:MemoryStream(data) 的 data 是照片原字节;new Bitmap(img)会重新解码一份位图,多花一点 CPU,但换来上层调用完全没有释放风险的安心。若你的程序对内存抠得极紧,也可以返回 Image 本身并保持流不被释放,然后把释放责任交给调用方——那种做法代码会更容易出错,我不推荐。
额外提醒:System.Drawing.Common 在 Linux 容器里跑需要安装 libgdiplus,否则 Image.FromStream 在 Docker 里直接抛 GDI+ 异常。生产环境如果是 Linux 部署,要么换 SkiaSharp/ImageSharp,要么保证基础镜像里带齐依赖。
4.3 缩略图显示与 Base64 传给前端:各自适用位置
WinForms/WPF 里列表页直接加载原图会卡,我通常写一个MakeThumbnail(Image src, int size)方法:src.GetThumbnailImage(size, size, null, IntPtr.Zero)生成小图,再传给 PictureBox。GetThumbnailImage 的坑是某些格式,尤其动画 GIF 和部分 PNG,调用时会返回黑图,所以我更倾向于用它生成后立即转成 Bitmap 保存,或者直接用 Graphics.DrawImage 手动缩放,后者稳定,但代码多几行。
Base64 转换只在需要把照片传给 HTTP 接口或前端<img src="data:image/jpeg;base64,...">时用:Convert.ToBase64String(data)。这个操作会让数据膨胀约 33%,且字符串拼接在内存里很吃紧,单张 10MB 图片转出来就是 13MB 的字符串,除非接口协议强制,否则不建议把它作为库内存储格式。库内保持 byte[],接口出口再做 Base64,边界清晰,排查也好定位。
4.4 批量列表查询:DataReader 读缩略图的写法
列表页几十个 id 用循环单查,往返次数太多。更实际的写法是一次 IN 查询加 DataReader:
public Dictionary<int, byte[]> GetThumbDict(List<int> ids) { if (ids == null || ids.Count == 0) return new Dictionary<int, byte[]>(); var result = new Dictionary<int, byte[]>(); string inClause = string.Join(",", ids); string sql = "SELECT id, photo_thumb FROM photo_table WHERE id IN (" + inClause + ");"; using (var conn = new MySqlConnection(_connStr)) using (var cmd = new MySqlCommand(sql, conn)) { conn.Open(); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { int key = reader.GetInt32(0); byte[] val = (byte[])reader[1]; result[key] = val; } } } return result; }逻辑说明:一次查询把所有缩略图拉回内存,字典按 id 组织,业务层拿到后直接按顺序填入列表。这里有个安全边界要说清:id 列表是强类型 int,拼接 IN 列表风险可控;但绝不能用同样的方式拼用户输入的字符串字段,那种场景必须走参数化展开。
参数说明:photo_thumb列对应前面说的缩略图列,如果你建表时没加,可以把 SELECT 改成 photo_data,但原图 5MB 一张的场景,列表接口带宽会扛不住。DataReader 保持在连接打开状态下顺序读,读完立即释放,不能用完不关等 GC 来兜底。
5. 避坑清单:连接、大小与编码的五个翻车现场
5.1 服务端两个最常拦路的错:最大包与 SSL
坑一:Packet for query is too large。
现象:插入照片时报Packet for query is too large (8388608 > 4194304),后面的照片也逐条跟着失败。
原因:MySQL 的max_allowed_packet默认值偏小,旧版本 1MB,MySQL 8 部分发行版 4MB,一条 INSERT 里塞进 8MB 照片必然超限。
解决:分两步。先临时执行SET GLOBAL max_allowed_packet=67108864;让当前实例立刻生效,值为 64MB;再进 MySQL 安装目录的 my.ini,在[mysqld]段添加一行max_allowed_packet=64M,重启服务让配置落盘。只做第一步的话,服务一重启就打回原形,生产环境必须改配置文件。
坑二:mysql ssl 连接错误。
现象:本地调试时 C# 连 MySQL 8 直接抛异常,错误信息里有 SSL Connection Error 字样,有时还带 certificate 相关提示。
原因:MySQL 8 默认开启 SSL 并自动生成自签名证书,而客户端驱动版本或加密方式跟服务端协商失败。
解决:本地调试时连接串加SslMode=None最快;生产要加密就配好正式证书后把 SslMode 改成 Required,同时确保驱动是 8.0 以上。别用老版本 Connector/NET 去连新 MySQL,会把问题搞得更难定位。
5.2 代码侧三个低频但致命的问题:编码、类型与流
坑三:中文文件名乱码。
现象:照片插入成功,但 photo_name 查出来是“??????”或乱码串。
原因:连接串没写CharSet=utf8mb4,或者建库时用了默认 latin1。字符集链路里任何一处漏掉,中文都可能变问号。
解决:连接串、CREATE DATABASE、表字段三处字符集全部统一成 utf8mb4。验证也简单:插一条中文名记录,重新 SELECT 出来比对字符串。如果乱码只出现在存量数据上,先确认老数据是不是已经以 latin1 写入,这种情况光改连接串救不回来。
坑四:AddWithValue 把 byte[] 当成字符串。
现象:插入时不报错,但照片读回来后用图片软件打不开,或字节数与源文件不一致。
原因:cmd.Parameters.AddWithValue("?data", imageBytes)让驱动按字符串推断参数类型,二进制被当成文本转义和重新编码后直接破坏。
解决:显式指定new MySqlParameter("?data", MySqlDbType.LongBlob) { Value = imageBytes }。写完代码随手检查每个 BLOB 参数的类型枚举,看到 AddWithValue 和 byte[] 组合就改掉。
坑五:Image.FromStream 报“参数无效”。
现象:库里数据看着完整,代码一还原图片就抛 ArgumentException。
原因:两种常见情况。一是 MemoryStream 的 Position 没有归零,比如同一个流先被别人读过一遍,游标停在末尾;二是坑四把二进制存坏了,读出来是无效图片数据。
解决:按顺序排查。先比较data.Length和源文件 Length,长度一致就回到代码,在Image.FromStream(ms)前显式ms.Position = 0;长度不一致就回查数据库里存的到底是什么,必要时把该行 BLOB 前几十字节 dump 出来看是不是文件头。
5.3 报错后先查这两个变量再谈代码
遇到 BLOB 相关报错,我建议先跑两条 SQL,排除服务端因素再翻代码:
SHOW VARIABLES LIKE 'max_allowed_packet'; SHOW VARIABLES LIKE 'character_set%';第一条看包上限是不是小于你最大的照片,第二条看字符集链路有没有被改动。很多“代码没问题”的疑难杂症,最后都是服务端配置和生产不一致,先把这两个变量对齐,能省下大量查日志的时间。
6. 把整段照片读写封装成 ImageRepository:预留缩略图与分表
最后一个可落地的技巧,是把上面散落的 InsertPhoto、GetPhotoData 收拢成一个仓储类。划清边界后,业务层只跟 ImageRepository 打交道,不知道 MySQL 二进制列的存在,以后换库或加缩略图列都不动上层代码。
public sealed class ImageRepository { private readonly string _connStr; public ImageRepository(string connStr) { _connStr = connStr; } public int Save(string photoName, byte[] data) { // 复用第3章的插入逻辑,参数类型固定为 LongBlob } public byte[] GetById(int id) { // 复用第4章的 ExecuteScalar 读取逻辑 } public Dictionary<int, byte[]> GetThumbDict(List<int> ids) { // 复用第4.4节的 DataReader 批量查询 } }仓储类我一般还会加两个方法:SaveWithThumbnail把原图和缩略图两列一并写入,GetThumbnailsByNames按文件名列表批量返回缩略图集合,这样列表页一个请求只往返一次数据库。后续如果 MySQL 单表照片量到了百万级,这个类也是分表逻辑的唯一落点,按 id 取模分 16 张表,或者按 upload_time 按月分表,接口返回结构不变,业务层完全无感。
写完这个仓储类后我养成了一个习惯:每次照片上传功能联调完,都强制自己跑一遍“单张 8MB 原图 → 连续 50 张批量 → 跨进程读取并按 MD5 比对”的冒烟脚本,全部通过才敢移交。这条链路把所有坑都串在一起,过一遍比看十遍文档都管事,希望帮到你。
本文还有配套的精品资源,点击获取