ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C#将图片存入SQLite:BLOB字节流读写与性能避坑实践

C#将图片存入SQLite:BLOB字节流读写与性能避坑实践 简介C#与SQLite结合存取图片的示例包基于.NET环境演示如何通过System.Data.SQLite完成图片二进制数据的写入与读取并配合PictureBox控件展示。操作逻辑清晰适合刚接触ADO.NET的初学者也适合为桌面应用加入轻量级本地图片存储的开发者可作为嵌入式场景下文件处理的学习参考。资源共49个文件压缩包约2.11MB。核心包含9个C#源码文件与项目配置另含7个图片资源、5个DLL运行库、3个可执行程序以及工程配置文件覆盖从建表、插入BLOB到读取显示的完整流程。目前已有2469人学习下载。示例清晰展示了连接SQLite、创建Images表、将图片文件转为byte[]插入数据库以及从数据库读出并加载到PictureBox等关键步骤。通过完整可运行的WinForms项目读者可以快速理解SQLite以二进制形式存储大对象的操作方法。整个工程结构紧凑从连接建立、参数化SQL到临时文件清理均有可参考写法便于在此基础上扩展自己的功能。1. C#把图片塞进SQLite先想清楚这是不是你要的路径做上位机或者本地工具的时候经常会遇到一个需求程序跑起来要显示几张图片日志里要留截图设备参数里要存一张配置图。最省事的想法就是把图片文件路径存到数据库里但路径一换、机器一改图就丢了。于是很多人把目光转向SQLite——轻量、单文件、免安装C#里用System.Data.SQLite或者Microsoft.Data.Sqlite就能连听起来很美。C#使用SQLite存取图片本质上是把图片当二进制数据写进BLOB字段再读出来还原成图片。这个过程能跑通但有几个地方和普通文本存取完全不同图片字节流怎么参数化写入、读出来怎么转成Image、大量图片怎么避免内存爆掉、以及数据库文件越写越大的问题。这篇文章按我实际做过的方案从选型、建表、写入、读出、踩坑到批量验证完整走一遍新手可以直接复现熟手可以重点看第5章和第6章的边界参数。2. SQLite存图片的本质BLOB字段与读写模型2.1 为什么存BLOB而不存文件路径SQLite支持的数据类型里BLOB就是用来存二进制大对象的。图片转成byte[]之后以参数形式绑定到BLOB列写入和读取都是字节流操作。相比存路径直接存BLOB的最大好处是数据自包含数据库文件拷到哪里图片就在哪里备份一个文件等于备份了全部数据。但代价也很明确。图片进库之后你就不能用外部看图工具直接打开它了调试时得先导出来数据库文件体积会明显膨胀一张500KB的JPG塞进去库文件大约增长550KB到600KB因为BLOB有页对齐和元数据开销而且每次读写都要做完整的字节流转换内存里会多一份数组。我一般这样判断图片总量在几千张以内、单张不超过2MB、对查询速度要求不高存BLOB完全可行。如果图片动辄几十MB或者总量几万张以上更合理的方案是文件系统存文件、数据库存路径两者各管一头。这篇文章的场景假设是前者——本地工具、上位机、小型管理系统。2.2 SQLite侧的连接串与表结构准备C#连接SQLite常用两个包System.Data.SQLite传统功能全和Microsoft.Data.Sqlite微软官方维护更轻。我用的是System.Data.SQLite因为它的兼容性更好尤其是在.NET Framework和.NET 6混用的项目里。NuGet里搜System.Data.SQLite.Core即可注意x86/x64要和你编译目标一致。连接串只需要一行string connStr Data Sourcepicstore.db;Version3;PoolingTrue;Max Pool Size10;;几个参数的作用Data Source是数据库文件路径Version3是SQLite版本格式不用改Pooling打开连接池可以显著减少频繁开关数据库的开销Max Pool Size限制池大小避免高并发时连接数失控。如果你只在一个线程里用Pooling开不开无所谓如果是上位机多线程采集图片入库建议开着。表结构我建议这样建CREATE TABLE IF NOT EXISTS image_store ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_name TEXT NOT NULL, image_type TEXT NOT NULL, image_data BLOB NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这里把图片原始内容和业务逻辑拆开id做主键image_name存原始文件名或业务编号image_type存扩展名.jpg/.png等image_data是BLOB列create_time留作排序和清理依据。BLOB列必须NOT NULL后面读出来的时候省去判空分支。2.3 图片转字节流的内存模型图片从磁盘读进内存再变成byte[]这一步看似简单坑其实不少。用File.ReadAllBytes是最直接的方式但大文件会一次性吃满内存用FileStream分段读更稳但代码量多。我的习惯是明确单张不超过10MB的场合直接ReadAllBytes有超大图的场合用FileStream每16KB读一次。// 小图直接读 byte[] data File.ReadAllBytes(C:\temp\test.jpg); // 大图分段读避免大对象堆(Large Object Heap)被一次性塞满 byte[] ReadLargeFile(string path) { using (FileStream fs new FileStream(path, FileMode.Open, FileAccess.Read)) { byte[] buffer new byte[fs.Length]; int read 0; while (read buffer.Length) { int bytes fs.Read(buffer, read, buffer.Length - read); if (bytes 0) break; read bytes; } return buffer; } }第二段代码每次读的字节数由fs.Read的返回值决定不是每次都读满整个buffer所以要用read变量累计进度。直接用fs.Read(buffer, 0, buffer.Length)的写法在大多数情况下也能工作但它不保证一次读满文件大时可能提前返回最后存进去的图片是截断的。这属于那种偶尔翻车、翻车了还很难发现的坑。3. C#写入图片到SQLite建表、参数化与事务3.1 最小可运行的写入代码写库的核心是SQLiteParameter绑定byte[]绝不能把字节数组直接拼进SQL字符串。二进制数据里可能包含任意字节拼字符串轻则格式错乱重则SQL注入或数据损坏。using (SQLiteConnection conn new SQLiteConnection(connStr)) { conn.Open(); using (SQLiteTransaction trans conn.BeginTransaction()) { using (SQLiteCommand cmd conn.CreateCommand()) { cmd.Transaction trans; cmd.CommandText INSERT INTO image_store (image_name, image_type, image_data) VALUES (name, type, data); cmd.Parameters.Add(new SQLiteParameter(name, device_001)); cmd.Parameters.Add(new SQLiteParameter(type, .jpg)); cmd.Parameters.Add(new SQLiteParameter(data, DbType.Binary)); cmd.Parameters[data].Value File.ReadAllBytes(C:\temp\device_001.jpg); cmd.ExecuteNonQuery(); } trans.Commit(); } }注意几个点参数化写法的意思是name、type、data三个占位符对应三个参数对象运行时SQLite会把它们替换为安全值不会走字符串拼接。data这个参数创建时指定了DbType.Binary这是给ADO.NET的提示让BLOB数据以原生二进制格式传递Value直接赋byte[]即可不要提前ToString。这里把BeginTransaction包在外面虽然只插入一张图时事务不是必需但养成写事务的习惯后批量插入时不会漏掉。SQLite是单写者的数据库写操作会锁库事务内的写操作只在提交那一刻才真正落盘对性能的影响非常明显——后面第3章会对比数据。3.2 批量写入事务与参数是唯一靠谱的组合上位机或者批量导入场景最常问的问题是几千张图写进SQLite要多久直接回答之前先给两个反例。第一种反例是循环里每次Open/Close连接每次单独Insert第二种反例是循环里不重用命令对象。这两种写法在1000张图时会慢到让人怀疑SQLite不行但它其实是被写法坑了。using (SQLiteConnection conn new SQLiteConnection(connStr)) { conn.Open(); using (SQLiteTransaction trans conn.BeginTransaction()) { string insertSql INSERT INTO image_store (image_name, image_type, image_data) VALUES (name, type, data); using (SQLiteCommand cmd conn.CreateCommand()) { cmd.CommandText insertSql; cmd.Transaction trans; SQLiteParameter pName new SQLiteParameter(name); SQLiteParameter pType new SQLiteParameter(type); SQLiteParameter pData new SQLiteParameter(data, DbType.Binary); cmd.Parameters.Add(pName); cmd.Parameters.Add(pType); cmd.Parameters.Add(pData); foreach (var file in Directory.GetFiles(C:\temp\images, *.jpg)) { pName.Value Path.GetFileNameWithoutExtension(file); pType.Value .jpg; pData.Value File.ReadAllBytes(file); cmd.ExecuteNonQuery(); } } trans.Commit(); } }这段代码把所有命令对象创建放在循环外循环里只改参数值再执行避免了频繁构造SQLCommand造成的开销。实测效果同一个库文件、同一批1000张2MB左右的JPG每次Open连接不加大事务大约耗时20秒加上事务并复用命令对象之后大约6到8秒。具体数字因磁盘而异但数量级差距就是这么明显。批量写入时还要注意一次事务不要包太多数据。5000张图一个事务的话SQLite提交时会把整个事务的数据刷盘中途出错全部回滚内存占用也高。我一般一个事务控制在1000张左右或者按总字节数控制在200MB以内既保证速度又不会让单个事务太脆弱。3.3 更新与删除图片记录更新图片通常用于替换场景比如设备图片发生变化、用户重新上传了图片。删除则是清理场景比如定期清理旧日志截图。这两个操作的共同坑点是很多人会忘记同时清理文件系统的临时文件或者更新时把BLOB字段置为NULL。// 更新图片 using (SQLiteConnection conn new SQLiteConnection(connStr)) { conn.Open(); using (SQLiteCommand cmd conn.CreateCommand()) { cmd.CommandText UPDATE image_store SET image_data data, image_type type WHERE id id; cmd.Parameters.Add(new SQLiteParameter(data, DbType.Binary)); cmd.Parameters.Add(new SQLiteParameter(type, .png)); cmd.Parameters.Add(new SQLiteParameter(id, 1001)); cmd.Parameters[data].Value File.ReadAllBytes(C:\temp\new_photo.png); int affected cmd.ExecuteNonQuery(); // affected 0 表示id不存在需要处理 } } // 删除图片 using (SQLiteConnection conn new SQLiteConnection(connStr)) { conn.Open(); using (SQLiteCommand cmd conn.CreateCommand()) { cmd.CommandText DELETE FROM image_store WHERE id id; cmd.Parameters.Add(new SQLiteParameter(id, 1001)); cmd.ExecuteNonQuery(); } }UPDATE里检查受影响行数是个好习惯affected等于0时要么id不存在要么图片本身没变化。有些场景图片比较大更新前先对比新旧图的字节数是否一致能省下不必要的写库操作。删除操作在SQLite里不会立即释放磁盘空间删掉的页面会标记为空闲页供后续复用如果想彻底压缩库文件体积需要执行VACUUM命令。VACUUM会重写整个数据库文件耗时和库大小成正比不要在业务运行时执行。4. 从SQLite读出图片字节流转Image与内存管理4.1 按ID读取单张图片并还原读图片的核心逻辑是执行SELECT拿byte[]然后转成内存流再用Bitmap或Image.FromStream还原。这里最容易翻车的点是流被提前释放或者流的位置不在开头。public Bitmap LoadImageById(int id) { string connStr Data Sourcepicstore.db;Version3;; using (SQLiteConnection conn new SQLiteConnection(connStr)) { conn.Open(); using (SQLiteCommand cmd conn.CreateCommand()) { cmd.CommandText SELECT image_data FROM image_store WHERE id id; cmd.Parameters.Add(new SQLiteParameter(id, id)); byte[] data (byte[])cmd.ExecuteScalar(); if (data null || data.Length 0) return null; using (MemoryStream ms new MemoryStream(data)) { // 用Bitmap的Clone把图片数据独立出来否则stream关闭后位图可能失效 using (Bitmap bmp new Bitmap(ms)) { return new Bitmap(bmp); } } } } }这里有两层using套在一起原因是Bitmap对象和MemoryStream存在生命周期耦合。直接new Bitmap(ms)之后立刻关闭ms在WinForms里多数情况没问题但在某些系统环境下会抛参数无效的异常这是因为GDIPlus延迟读取了底层数据。用Clone拷贝一份像素数据再做返回值是通用的保险写法。返回值需要调用方负责Dispose这也是WinForms编程里最容易漏的一步——位图对象释放不及时程序运行一小时后内存飙升。如果你在WPF里用返回类型应该是ImageSource而不是Bitmap。System.Windows.Interop命名空间下的Imaging.CreateBitmapSourceFromHBitmap可以把Bitmap转成BitmapSource但记得调用BitmapSource的Freeze方法防止跨线程访问时抛异常。4.2 列表页只载缩略图别把几百张原图一次性读进内存这是SQLite存图片最常见的性能事故起火点。很多人写列表功能时直接SELECT所有记录的image_data然后在ListView里全部显示。图片总量少还好100张2MB的图就是200MB内存——程序还没崩但已经接近黑匣子了界面卡顿、占用飙升、很难排查。正确做法是分两步查。第一步查询时排除BLOB列只查出id、image_name、image_type等元数据绑定到列表控件第二步等用户点了某一行或者列表滚动到某个缩略图位置时再按需读取那一张图片。SQLite查询指定列会用更少的硬盘IO而且不在内存里积压大字节数组。如果列表确实需要显示缩略图我常用的方案是在写入时额外生成一份小尺寸缩略图存到image_data列旁边再加一列thumb_data列表读thumb_data详情读image_data。这个方案在建表时就要预留列比较适合业务结构稳定的项目。另外一个取舍是查询时用LIMIT分页每页只取30行元数据滑动列表时按页加载这个做法通用性最强不依赖前端控件。// 分页读取元数据不取BLOB列 string sql SELECT id, image_name, image_type, create_time FROM image_store ORDER BY id DESC LIMIT offset, count;LIMIT的写法在SQLite里需要注意第一个参数是偏移量第二个参数是行数。OFFSET分页在数据量大时性能会下降因为它要扫描前面所有的记录才能找到起点。图片存储场景一般总量不大够用就好如果总量到了十几万条要么基于id做游标分页WHERE id 最后一条id ORDER BY id DESC LIMIT 30要么就重新评估存BLOB这个方案了。4.3 WinForms与WPF的图片绑定差异WinForms里图片控件的Image属性是System.Drawing.Image类型上面4.1节返回的Bitmap可以直接赋值。WPF里Image控件的Source属性是ImageSource类型需要做一层转换。// WPF: byte[]转BitmapSource using (MemoryStream ms new MemoryStream(data)) { BitmapImage bmpImage new BitmapImage(); bmpImage.BeginInit(); bmpImage.StreamSource ms; bmpImage.CacheOption BitmapCacheOption.OnLoad; bmpImage.EndInit(); bmpImage.Freeze(); // 跨线程安全UI线程外创建时必须有 return bmpImage; }BitmapCacheOption.OnLoad的用意是让BitmapImage在EndInit时就把图片数据全部解码进内存而不是像默认的OnDemand那样延迟到渲染时才解码。用OnLoad之后StreamSource被释放也不影响显示这对从数据库读出的场景很关键——因为MemoryStream的生命周期不在你控制范围内。Freeze只对位图源生效它把BitmapSource变成不可变对象从而允许跨线程共享。如果你的图片数据是后台线程读库拿到的不Freeze会在绑定到UI时抛异常。网上很多报错帖调用线程无法访问此对象因为另一个线程拥有该对象就是这个原因。5. 避坑清单SQLite存取图片最常见的5个坑5.1 图片字节流被截断打开却报无效的图片格式现象图片成功写库、成功读出但用Bitmap加载时报参数无效或者用外部工具看图片文件提示格式损坏。原因最常见的两个来源。一是写库前读取文件时用了单次Read没循环。File.ReadAllBytes本身没问题问题出在有人自己写FileStream.Read单次读取文件。二是读出后用StreamReader处理byte[]导致二进制数据被当作文本自动做了编码转换。解决二进制数据全程用byte[]承载读文件用ReadAllBytes或分段循环Read写库用SQLiteParameter DbType.Binary出库用MemoryStream包byte[]再交给Bitmap或BitmapImage。中间不经过任何string或char类型。5.2 SQLite库文件被占用另一进程无法打开现象程序A打开picstore.db的写连接未释放程序B比如DB Browser for SQLite去查看时报database is locked。原因SQLite默认允许一个写者其他进程的写操作会被阻塞一段时间默认busy_timeout是0意思是一遇到锁立刻放弃并返回错误。注意锁的不只是写如果写连接一直没提交读也可能受影响。解决先确认连接和命令都用using包好并正确释放这是最基础的一条。然后设置合理的busy_timeoutstring connStr Data Sourcepicstore.db;Version3;Busy Timeout5000;;这个连接串参数的值单位是毫秒5000表示锁等待5秒。把连接串放到配置文件里统一管理而不是散落在各处。如果多个进程频繁读写同一个库文件还是建议换成WAL日志模式需要在连接串上加Journal ModeWal。WAL模式下读和写可以并发执行但WAL文件会积累需要周期性的checkpoint来合并回主库。5.3 图片写入后数据库文件体积比预期大很多现象20张2MB的图片写进去库文件从几十KB涨到50MB而不是预期的40MB多一点。原因SQLite是按页分配空间的默认页大小4096字节一个4MB的BLOB会横跨多个页最后一页写不满也照样占用。更关键的是SQLite写入新数据时空闲页可能碎片化以致数据库文件膨胀速度超过数据实际量。解决如果要精确控制库体积可以调整页大小再建库PRAGMA page_size8192或PRAGMA page_size16384大BLOB用大页能减少跨页的数量。这个配置必须在建库之前设置库一旦建好再改page_size需要重建全库。还有一个常见的空间回收操作是VACUUM它在删除大量图片后执行一次能压缩出可观的体积但耗时和库大小成正比建议在程序空闲时触发。5.4 并发写入发生database is locked且程序无响应现象上位机多个采集线程同时向同一个SQLite数据库写图片偶发database is locked异常而且重试也可能继续失败。原因SQLite的锁粒度是库级而不是表级。两个线程同时发INSERT一个拿到了写锁另一个只能等阻塞或者立刻报错。C#的Task并行、线程池并行会把这个问题放大因为你以为它们是并发的实际数据库在排队。解决第一个办法是全局串行化写操作——做一个独立的写队列所有写入请求进队列单线程消费。第二个办法是开启WAL模式并在连接串设置Busy Timeout让并发写变成短暂的等待而不是直接报错。第三个办法是不要把SQLite当高并发数据库用分库分表只在极个别场景有意义。我的建议图片写入场景几乎不存在瞬时高吞吐的需求直接串行化最稳。5.5 读库时出现Access Violation或进程崩溃现象C#调用SQLite相关库时偶发AccessViolationException错误码类似c0000005这通常在程序运行一段时间后出现。原因多数情况下是System.Data.SQLite原生库SQLite.Interop.dll加载了不同位数的版本。程序编译成x86但SQLite.Interop.dll是x64或者反过来。两种位数的DLL混用内存寻址错位崩溃只是时间问题。另一个可能是连接对象在GC回收后底层句柄被重复释放。解决先在项目里确认平台目标统一为x86或x64并且NuGet包里的SQLite.Interop.dll目录也与目标平台一致。再检查数据库连接对象的释放逻辑确保using的嵌套层级正确不要手动调用Dispose然后又让using再Dispose一次双重释放容易触发原生层的AccessViolation。如果项目是AnyCPU编译的改为强制x64或强制x86是更可控的选择。6. 批量存取实测与验证这个方案到底值不值得用验证方案能不能落地我一般分三步走用DB Browser for SQLite直接浏览库内数据确认BLOB字段真实写进去了写一段计时程序统计批量写入和读取的吞吐量最后用你的真实业务图库压一遍边界——图片总量、单张大小、库文件体积这三个数字如果都在合理区间就可以上线。计时程序的核心逻辑很简单写入1000张、2000张、5000张图片分别统计秒数读取也一样连续读出N张图片并转成Bitmap统计秒数。记录两个数字每张平均耗时、内存峰值。如果你用任务管理器看内存峰值发现读500张图内存涨了1GB那缩略图策略必须做如果写入5000张只花了30秒说明事务参数已经调试到位。我个人用这套方案做过一个设备检测工具每天生成200张左右的JPG截图单张1.5MB上下一个月下来库文件大约700MB读取最近一周图片做回放耗时在1秒以内。这个量级SQLite完全顶得住而且数据库文件直接备份就走没有乱七八糟的目录结构。但同样的库跑到几十万张的时候回放开始卡顿VACUUM一次要等几分钟缩略图列成了必须项。最后说一个我的习惯写库前先检查图片是否已存在用image_name做唯一约束或者先SELECT一次。重复写入在大批量导入时很常见而BLOB数据本身没法做索引唯一能卡住重复的就是名称字段。这算是一个小设计但能省掉后面清理重复数据的体力活。这个方向整体是值得做的只要你控制好场景和参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表