10、BLOB大对象存储:二进制数据存储、图片/文件入库、读取与还原
说实话,很多刚入行的朋友一听到BLOB就头大。觉得二进制数据嘛,不就是一堆0和1,存进去读出来不就完了?嗯,道理是这个道理,但真做起来,坑可不少。
我个人习惯把BLOB比作一个黑箱子。你把文件扔进去,它帮你存着。但怎么扔、怎么取、取出来是不是原封不动,这里头就有讲究了。今天咱们就把这个黑箱子拆开看看。
什么是BLOB?
BLOB全称是Binary Large Object,翻译过来就是二进制大对象。说白了,就是用来存那些不能用普通文本表示的数据。比如图片、音频、视频、压缩包、Word文档等等。
在SQLite里,BLOB类型就是直接存二进制字节流。没有编码转换,没有字符集问题。你给什么,它就存什么。这一点跟TEXT类型有本质区别。
| 类型 | 存储内容 | 典型用途 |
|---|---|---|
| TEXT | 字符串(UTF-8/16) | 文本、JSON、XML |
| BLOB | 原始字节流 | 图片、文件、加密数据 |
我在项目中遇到过有人用TEXT存图片,结果存进去再读出来,图片就花了。为什么?因为TEXT会做编码转换,把二进制数据当字符串处理,某些字节就被"修正"了。所以记住:二进制数据,必须用BLOB。
BLOB的存储方式
SQLite对BLOB的处理其实很直接。它不关心你存的是什么,只关心字节长度。你传一个字节数组进去,它就原样存下来。读取时也是原样返回。
但这里有个性能问题。SQLite默认每个BLOB最大是1GB。听起来很大对吧?但如果你频繁读写几百MB的大文件,数据库文件会膨胀得很快。我个人建议,超过10MB的文件,还是存文件路径更合适。数据库里只存路径字符串,文件放磁盘上。
图片入库:从文件到BLOB
咱们来看一个实际例子。把一张图片存到SQLite里,需要三步:打开文件、读取字节、绑定到SQL语句。
// 图片入库示例
int store_image(sqlite3 *db, const char *filename) {
sqlite3_stmt *stmt;
FILE *fp;
long file_size;
unsigned char *buffer;
// 1. 打开文件
fp = fopen(filename, "rb");
if (!fp) return -1;
// 2. 获取文件大小
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
rewind(fp);
// 3. 分配缓冲区并读取
buffer = (unsigned char*)malloc(file_size);
fread(buffer, 1, file_size, fp);
fclose(fp);
// 4. 准备SQL语句
const char *sql = "INSERT INTO images(name, data) VALUES(?, ?)";
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
// 5. 绑定文件名和BLOB数据
sqlite3_bind_text(stmt, 1, filename, -1, SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 2, buffer, file_size, SQLITE_TRANSIENT);
// 6. 执行
sqlite3_step(stmt);
sqlite3_finalize(stmt);
free(buffer);
return 0;
}
注意看sqlite3_bind_blob这个函数。它的最后一个参数是析构回调。用SQLITE_TRANSIENT表示SQLite会自己拷贝一份数据。如果你用SQLITE_STATIC,那你就得保证buffer在SQL语句执行完之前一直有效。
SQLITE_STATIC,然后紧接着就free了buffer。结果SQLite执行时读到了野指针,程序直接崩溃。后来就老老实实用SQLITE_TRANSIENT了。
读取与还原:从BLOB到文件
读取BLOB数据,核心是sqlite3_column_blob和sqlite3_column_bytes。前者拿数据指针,后者拿数据长度。
// 读取BLOB并还原为文件
int read_image(sqlite3 *db, int id, const char *output_path) {
sqlite3_stmt *stmt;
FILE *fp;
const char *sql = "SELECT name, data FROM images WHERE id = ?";
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
sqlite3_bind_int(stmt, 1, id);
if (sqlite3_step(stmt) == SQLITE_ROW) {
// 获取BLOB数据
const void *blob_data = sqlite3_column_blob(stmt, 1);
int blob_size = sqlite3_column_bytes(stmt, 1);
// 写入文件
fp = fopen(output_path, "wb");
fwrite(blob_data, 1, blob_size, fp);
fclose(fp);
printf("还原成功,大小:%d 字节\n", blob_size);
}
sqlite3_finalize(stmt);
return 0;
}
这里有个细节:sqlite3_column_blob返回的指针指向的是SQLite内部缓冲区。你必须在调用sqlite3_step获取下一行之前,把数据用完或者拷贝出来。否则指针就失效了。
BLOB操作的性能考量
你想想看,如果一张表里存了几百张图片,每张几MB。那么查询时,即使你只想要文件名,SQLite也得把整个BLOB加载到内存里。这显然不合理。
我的做法是:把BLOB单独放一张表,用外键关联。查询主表时不加载BLOB,只有需要时才去读BLOB表。
-- 推荐的表结构
CREATE TABLE file_meta (
id INTEGER PRIMARY KEY,
name TEXT,
size INTEGER,
mime_type TEXT
);
CREATE TABLE file_data (
id INTEGER PRIMARY KEY,
meta_id INTEGER UNIQUE,
content BLOB,
FOREIGN KEY (meta_id) REFERENCES file_meta(id)
);
这样设计的好处是:列表查询只扫file_meta表,速度快。只有用户点击下载时,才去读file_data表里的BLOB。
BLOB的增量读写
SQLite还支持增量读写BLOB。什么意思呢?就是你可以只修改BLOB的一部分,不用把整个BLOB读出来再写回去。这个功能叫Incremental BLOB I/O。
我记得有一次做固件升级功能,需要往数据库里的BLOB末尾追加数据。如果用传统方式,得先把整个BLOB读出来,追加,再写回去。几十MB的数据,来回拷贝,效率极低。
用增量I/O就简单了:
// 增量写入BLOB
sqlite3_blob *blob_handle;
int rc = sqlite3_blob_open(db, "main", "file_data", "content",
row_id, 1, &blob_handle);
if (rc == SQLITE_OK) {
// 从偏移量100开始写入1024字节
sqlite3_blob_write(blob_handle, data_buffer, 1024, 100);
sqlite3_blob_close(blob_handle);
}
这个API直接操作数据库文件中的BLOB区域,避免了数据在应用层和数据库层之间的拷贝。对于大文件处理,性能提升非常明显。
避坑指南
做BLOB开发这几年,我总结了几条血泪教训:
- 不要用TEXT存二进制——编码转换会破坏数据,这是最基础的坑
- 大文件别直接存BLOB——超过50MB的文件,数据库性能会急剧下降
- 记得设置缓存大小——
PRAGMA cache_size调大一些,BLOB读写会快很多 - 注意内存占用——读取大BLOB时,确保有足够的内存,否则程序会OOM
- 事务要合理使用——批量插入BLOB时,用事务包裹,否则每条记录都触发一次磁盘写入
知识体系总览
下面这张图把BLOB的核心知识点串起来了。从存储方式到读写策略,再到性能优化,你可以对照着梳理自己的理解。
BLOB这东西,说难不难,说简单也不简单。关键是要理解它的本质——就是一堆字节。你把它当文件处理,用fread/fwrite的思路去理解,就顺了。别被"大对象"三个字吓到,它就是个存储容器而已。
好了,这一章的内容就到这儿。代码示例都是我在实际项目中验证过的,你可以直接拿去用。遇到问题,多看看SQLite官方文档,那里写得其实挺清楚的。