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_lengthlength 这两个字段搞混了。记住,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 处理的核心流程串起来了:

MySQL BLOB 数据处理核心流程 数据来源 图片 / 文件 / 二进制流 处理方式 小文件:一次性读写 处理方式 大文件:流式分块读写 mysql_stmt_bind_param 绑定 MYSQL_BIND 结构体 mysql_stmt_send_long_data 分块发送(每块 64KB) mysql_stmt_fetch 两步法:先取长度再取数据 mysql_stmt_execute 写入数据库 mysql_stmt_execute 流式写入完成 fwrite 到磁盘 恢复文件 核心原则:小文件一次性处理,大文件流式分块,避免内存爆炸

性能建议

💡 我的几点经验:
  • BLOB 字段尽量放在表的最后。MySQL 行存储格式中,变长字段靠后存放,查询非 BLOB 字段时能跳过数据块。
  • 如果业务允许,把 BLOB 单独放一张表,用外键关联。这样主表查询快,备份时也灵活。
  • 网络传输 BLOB 时,考虑压缩。我在项目中用 zlib 压缩后再存,平均能省 40% 空间。
  • 连接池要调大 net_buffer_length,默认 16KB 对于 BLOB 操作太小了,我一般设到 1MB。

好了,BLOB 这块就聊到这儿。说白了就是「小文件直接干,大文件分块干」。你只要记住 mysql_stmt_send_long_data 这个函数,大部分流式场景都能搞定。下次遇到要存图片、存文件的需求,别怵,直接上 BLOB。


公众号:蓝海资料掘金营,微信 deep3321