28、MySQL BLOB数据处理:二进制数据存储、图片/文件存取、流式读写
说实话,BLOB 这个类型,很多 C 程序员一开始都绕着走。为啥?因为大家习惯了处理字符串和数字,突然要往数据库里塞一张图片、一个 PDF 文件,总觉得别扭。我早年做嵌入式监控系统时,也踩过这个坑——摄像头抓拍的图片到底存文件系统还是存数据库?后来项目要求数据必须集中管理、方便备份,我才认真把 BLOB 用起来。
今天咱们就把 BLOB 这层窗户纸捅破。说白了,它就是数据库里的二进制大对象。你存什么?图片、音频、压缩包、甚至一个小型视频片段,都行。关键是怎么用 C 语言高效地读写它。
BLOB 类型家族
MySQL 提供了四种 BLOB 类型,区别只在于最大容量:
| 类型 | 最大长度 | 适用场景 |
|---|---|---|
| TINYBLOB | 255 字节 | 小图标、短签名 |
| BLOB | 65,535 字节(64KB) | 中等图片、文档片段 |
| MEDIUMBLOB | 16,777,215 字节(16MB) | 高清图片、PDF 文件 |
| LONGBLOB | 4,294,967,295 字节(4GB) | 视频、大型压缩包 |
我个人习惯:存普通照片用 MEDIUMBLOB,存文档用 BLOB。别一上来就 LONGBLOB,那玩意儿内存吃不消。
建表与准备
先看表结构。假设我们要做一个文件存储系统:
CREATE TABLE file_store (
id INT PRIMARY KEY AUTO_INCREMENT,
file_name VARCHAR(255) NOT NULL,
file_type VARCHAR(50),
file_size INT,
file_data MEDIUMBLOB,
upload_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
嗯,这里要注意:file_data 字段就是存二进制数据的。我建议你永远加上 file_size 字段,方便做内存预分配,避免反复 realloc。
写入 BLOB:从文件到数据库
写入的核心思路就三步:打开文件 → 读入内存 → 绑定参数执行 INSERT。
我用 MySQL 的 mysql_stmt_bind_param 配合 MYSQL_BIND 结构体来做。为什么用预处理语句?因为 BLOB 数据量大,直接拼 SQL 容易出转义问题,而且性能差。
#include <mysql/mysql.h>
#include <stdio.h>
#include <stdlib.h>
int insert_blob(MYSQL *conn, const char *file_path) {
FILE *fp = fopen(file_path, "rb");
if (!fp) {
fprintf(stderr, "无法打开文件: %s\n", file_path);
return -1;
}
// 获取文件大小
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
rewind(fp);
// 分配内存读入文件
unsigned char *buffer = (unsigned char *)malloc(file_size);
if (!buffer) {
fclose(fp);
return -1;
}
fread(buffer, 1, file_size, fp);
fclose(fp);
// 构造 SQL
const char *sql = "INSERT INTO file_store(file_name, file_type, file_size, file_data) VALUES(?, ?, ?, ?)";
MYSQL_STMT *stmt = mysql_stmt_init(conn);
if (!stmt) {
free(buffer);
return -1;
}
mysql_stmt_prepare(stmt, sql, strlen(sql));
// 绑定参数
MYSQL_BIND bind[4];
memset(bind, 0, sizeof(bind));
char name[256] = "test_photo.jpg";
char type[16] = "image/jpeg";
unsigned long name_len = strlen(name);
unsigned long type_len = strlen(type);
unsigned long data_len = file_size;
bind[0].buffer_type = MYSQL_TYPE_STRING;
bind[0].buffer = name;
bind[0].buffer_length = 256;
bind[0].length = &name_len;
bind[1].buffer_type = MYSQL_TYPE_STRING;
bind[1].buffer = type;
bind[1].buffer_length = 16;
bind[1].length = &type_len;
bind[2].buffer_type = MYSQL_TYPE_LONG;
bind[2].buffer = &file_size;
bind[2].is_unsigned = 0;
bind[3].buffer_type = MYSQL_TYPE_BLOB;
bind[3].buffer = buffer;
bind[3].buffer_length = file_size;
bind[3].length = &data_len;
mysql_stmt_bind_param(stmt, bind);
if (mysql_stmt_execute(stmt)) {
fprintf(stderr, "插入失败: %s\n", mysql_stmt_error(stmt));
free(buffer);
mysql_stmt_close(stmt);
return -1;
}
printf("成功插入文件,大小: %ld 字节\n", file_size);
free(buffer);
mysql_stmt_close(stmt);
return 0;
}
我在项目中遇到过一个问题:buffer_length 和 length 这两个字段搞混了。记住,buffer_length 是缓冲区总大小,length 指向实际数据长度。对于 BLOB,这两个值通常一样,但如果你只填了一部分缓冲区,那 length 就很重要了。
读取 BLOB:从数据库到文件
读取就是写入的逆过程。用 mysql_stmt_fetch 把数据拉出来,然后写到磁盘上。
int read_blob_to_file(MYSQL *conn, int file_id, const char *output_path) {
const char *sql = "SELECT file_name, file_type, file_size, file_data FROM file_store WHERE id = ?";
MYSQL_STMT *stmt = mysql_stmt_init(conn);
mysql_stmt_prepare(stmt, sql, strlen(sql));
// 绑定输入参数
MYSQL_BIND param[1];
memset(param, 0, sizeof(param));
param[0].buffer_type = MYSQL_TYPE_LONG;
param[0].buffer = &file_id;
mysql_stmt_bind_param(stmt, param);
// 准备接收结果
MYSQL_BIND result[4];
memset(result, 0, sizeof(result));
char file_name[256];
char file_type[32];
unsigned long name_len, type_len, data_len;
long file_size;
// 先获取长度,再分配内存
result[0].buffer_type = MYSQL_TYPE_STRING;
result[0].buffer = file_name;
result[0].buffer_length = 256;
result[0].length = &name_len;
result[1].buffer_type = MYSQL_TYPE_STRING;
result[1].buffer = file_type;
result[1].buffer_length = 32;
result[1].length = &type_len;
result[2].buffer_type = MYSQL_TYPE_LONG;
result[2].buffer = &file_size;
// BLOB 字段先绑定一个空缓冲区,用于获取长度
result[3].buffer_type = MYSQL_TYPE_BLOB;
result[3].buffer = NULL;
result[3].buffer_length = 0;
result[3].length = &data_len;
mysql_stmt_bind_result(stmt, result);
mysql_stmt_execute(stmt);
mysql_stmt_fetch(stmt);
// 现在 data_len 里是实际数据长度
unsigned char *blob_data = (unsigned char *)malloc(data_len);
if (!blob_data) return -1;
// 重新绑定真实缓冲区
result[3].buffer = blob_data;
result[3].buffer_length = data_len;
mysql_stmt_bind_result(stmt, result);
mysql_stmt_fetch(stmt);
// 写入文件
FILE *fp = fopen(output_path, "wb");
if (!fp) {
free(blob_data);
return -1;
}
fwrite(blob_data, 1, data_len, fp);
fclose(fp);
printf("文件已保存到: %s (大小: %lu 字节)\n", output_path, data_len);
free(blob_data);
mysql_stmt_close(stmt);
return 0;
}
你想想看,这里有个小技巧:第一次 fetch 时 BLOB 字段绑 NULL,MySQL 会返回实际长度。然后你根据这个长度分配内存,再 fetch 一次拿到真实数据。这叫「两步走」策略,避免了一次性分配过大内存。
流式读写:大文件怎么办?
如果文件超过几百 MB,一次性读入内存就不现实了。MySQL C API 支持流式读写,说白了就是分块传输。
写入时,用 mysql_stmt_send_long_data 分块发送:
int stream_insert_blob(MYSQL *conn, const char *file_path) {
FILE *fp = fopen(file_path, "rb");
if (!fp) return -1;
const char *sql = "INSERT INTO file_store(file_name, file_type, file_size, file_data) VALUES(?, ?, ?, ?)";
MYSQL_STMT *stmt = mysql_stmt_init(conn);
mysql_stmt_prepare(stmt, sql, strlen(sql));
// 先绑定前三个参数
MYSQL_BIND bind[4];
memset(bind, 0, sizeof(bind));
char name[256] = "large_file.zip";
char type[16] = "application/zip";
unsigned long name_len = strlen(name);
unsigned long type_len = strlen(type);
long file_size = 0;
// 计算文件大小
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
rewind(fp);
bind[0].buffer_type = MYSQL_TYPE_STRING;
bind[0].buffer = name;
bind[0].buffer_length = 256;
bind[0].length = &name_len;
bind[1].buffer_type = MYSQL_TYPE_STRING;
bind[1].buffer = type;
bind[1].buffer_length = 16;
bind[1].length = &type_len;
bind[2].buffer_type = MYSQL_TYPE_LONG;
bind[2].buffer = &file_size;
// BLOB 参数先绑定 NULL,后面用 send_long_data
bind[3].buffer_type = MYSQL_TYPE_BLOB;
bind[3].buffer = NULL;
bind[3].buffer_length = 0;
mysql_stmt_bind_param(stmt, bind);
// 分块发送数据,每块 64KB
unsigned char chunk[65536];
size_t bytes_read;
int chunk_index = 0;
while ((bytes_read = fread(chunk, 1, sizeof(chunk), fp)) > 0) {
if (mysql_stmt_send_long_data(stmt, 3, chunk, bytes_read)) {
fprintf(stderr, "发送第 %d 块失败\n", chunk_index);
fclose(fp);
mysql_stmt_close(stmt);
return -1;
}
chunk_index++;
}
fclose(fp);
// 执行语句
if (mysql_stmt_execute(stmt)) {
fprintf(stderr, "流式插入失败: %s\n", mysql_stmt_error(stmt));
mysql_stmt_close(stmt);
return -1;
}
printf("流式插入完成,共 %d 块,总大小: %ld 字节\n", chunk_index, file_size);
mysql_stmt_close(stmt);
return 0;
}
我曾经用这个方式往数据库里塞过一个 1.2GB 的日志压缩包。内存占用始终控制在 64KB 左右,非常稳。注意 mysql_stmt_send_long_data 的第二个参数是参数索引(从 0 开始),BLOB 字段排在第 4 位,所以索引是 3。
BLOB 使用避坑指南
- max_allowed_packet 限制:MySQL 默认最大包是 4MB。如果你插入超过这个大小的 BLOB,会直接报错。记得在 my.cnf 里调大:
max_allowed_packet=256M。 - 事务太大:BLOB 数据会占用大量 undo log。我建议每个大 BLOB 单独一个事务,别跟其他业务逻辑混在一起。
- 索引问题:别给 BLOB 字段加索引。MySQL 不允许对 BLOB/TEXT 全字段建索引,只能建前缀索引,但意义不大。
- 备份恢复:数据库里塞了大量 BLOB 后,mysqldump 导出的 SQL 文件会巨大无比。我后来改用
mysqlpump或者直接物理备份。
知识体系总览
下面这张图把 BLOB 处理的核心流程串起来了:
性能建议
- BLOB 字段尽量放在表的最后。MySQL 行存储格式中,变长字段靠后存放,查询非 BLOB 字段时能跳过数据块。
- 如果业务允许,把 BLOB 单独放一张表,用外键关联。这样主表查询快,备份时也灵活。
- 网络传输 BLOB 时,考虑压缩。我在项目中用 zlib 压缩后再存,平均能省 40% 空间。
- 连接池要调大
net_buffer_length,默认 16KB 对于 BLOB 操作太小了,我一般设到 1MB。
好了,BLOB 这块就聊到这儿。说白了就是「小文件直接干,大文件分块干」。你只要记住 mysql_stmt_send_long_data 这个函数,大部分流式场景都能搞定。下次遇到要存图片、存文件的需求,别怵,直接上 BLOB。