BYTEA vs 大对象
存二进制数据到 PostgreSQL,只有两条路:bytea 字段,或者大对象(Large Object)。前两节把大对象讲透了,这一节做选型:什么时候用 bytea,什么时候必须上大对象,两者的边界在哪里。
先说结论:绝大多数场景应该用 bytea。大对象只在两个条件下才有明显优势——单个对象超过 1 GB,或者需要高频随机读写对象内部的一小段。除此之外,大对象带来的复杂度(事务约束、孤儿对象、不支持逻辑复制、备份注意事项)都是净负担。
本节围绕一张对比表展开,把各个维度的差异拆开,然后给出决策清单、两种格式的互转 SQL、以及真实的 PHP / Python 上传下载示例。
前置知识
基础概念
两条路的本质差异
bytea 是一个普通的数据类型,它的值就是行的值本身,跟着行一起存储、一起事务、一起删除。
大对象是一套独立的存储机制,它的内容存在系统表里,业务表只保存一个 oid 引用。
── bytea ──────────────────────────────
表 photos
┌────┬──────────┬───────────────────────┐
│ id │ title │ raster │
├────┼──────────┼───────────────────────┤
│ 1 │ a.png │ \x89504e47...(内联或TOAST) │
└────┴──────────┴───────────────────────┘
↑ 数据在这里
── 大对象 ─────────────────────────────
表 photos
┌────┬──────────┬───────┐
│ id │ title │ raster│ ← 只存一个 OID
├────┼──────────┼───────┤
│ 1 │ a.png │ 16385 │
└────┴──────────┴───────┘
│
│ 引用
▼
系统表 pg_largeobject
┌───────┬────────┬──────────────┐
│ loid │ pageno │ data │ ← 数据在这里,分页存放
├───────┼────────┼──────────────┤
│ 16385 │ 0 │ \x89504e47.. │
│ 16385 │ 1 │ ... │
└───────┴────────┴──────────────┘这个结构差异,直接决定了两者在下面所有维度上的表现。
上限:1 GB vs 4 TB
最直观的差别是容量上限。
| 类型 | 单值上限 | 依据 |
|---|---|---|
bytea(以及所有 varlena 类型) | 1 GB | varlena 长度字段为 32 位有符号,最大值 2³⁰−1 字节 |
| 大对象 | 约 4 TB | 分页存储 + 64 位偏移函数 |
bytea 的 1 GB 上限不是"PostgreSQL 配置的",而是类型本身的编码限制——所有变长类型(text、jsonb、bytea……)共享同一个 32 位头部,其中一位用作标志位,剩余 31 位表示长度,所以理论上限是 2³¹−1 字节,但实际可用上限被限制在 2³⁰−1 ≈ 1 GB。
大对象没有这个限制,因为它的长度不是"存在值头部",而是"由最大页号决定",配合 lo_lseek64 / lo_tell64 / lo_truncate64 的 64 位偏移,可寻址空间达到 TB 级。
这条是选型的第一个分水岭:单个对象超过 1 GB,bytea 直接出局。
TOAST:bytea 的"幕后分片"
bytea 看起来是"整值存储",但那只是逻辑视角。物理上,当值超过了单页能容纳的大小,PostgreSQL 会启用 TOAST(The Oversized-Attribute Storage Technique) 把它搬到独立的 TOAST 表里。
理解 TOAST 的几个常量很重要:
| 常量 | 默认值 | 含义 |
|---|---|---|
BLCKSZ | 8 kB | 数据块大小 |
TOAST_TUPLE_THRESHOLD | 2 kB | 行大小超过它就触发 TOAST |
TOAST_TUPLE_TARGET | 2 kB | 压缩后希望压缩到的目标大小 |
TOAST_MAX_CHUNK_SIZE | 约 2000 字节 | 每个 TOAST chunk 的最大尺寸 |
触发条件:当一行的大小超过 TOAST_TUPLE_THRESHOLD(约 2 kB)时,PostgreSQL 会尝试压缩该行中的可 TOAST 字段;如果压缩后仍然超限,就把这些字段的值切成分片(chunk)搬进关联的 TOAST 表,主表行里只留一个指针。
-- 查看某张表的 TOAST 表及其大小(photos 的 TOAST 表约 12 MB)
SELECT relname,
pg_size_pretty(pg_total_relation_size(reltoastrelid)) AS toast_size
FROM pg_class
WHERE relname = 'photos' AND reltoastrelid <> 0;bytea 列的存储策略默认为 EXTENDED(允许压缩 + 允许外存),这意味着:
- 数据会被尝试压缩(
bytea内容如果是文本型数据,压缩率可能很高); - 压缩后仍然超限时搬到 TOAST 表。
-- 查看列的存储策略:id 为 p,title / raster 均为 x
SELECT attname, attstorage
FROM pg_attribute
WHERE attrelid = 'photos'::regclass AND attnum > 0;attstorage 的取值含义:
| 值 | 策略 | 含义 |
|---|---|---|
p | PLAIN | 不允许压缩,不允许外存(定长类型) |
e | EXTENDED | 允许压缩,允许外存(默认,最灵活) |
x | EXTERNAL | 允许外存,但不压缩 |
m | MAIN | 优先压缩,实在放不下才外存 |
关键点来了:bytea 通过 TOAST 也能实现"分片存储",但它和你想要的完全不同。
TOAST 的分片是透明的、不可寻址的——你不能对 TOAST 的某个 chunk 做定点读写。每次访问 bytea 值,PostgreSQL 都需要把整个值(或者至少是访问所需的部分)解压、重组。所以:
- TOAST 让
bytea突破了单页限制,但没有突破 1 GB 的总上限; - TOAST 的读取仍然是"整值语义",不支持按偏移随机访问;
- 对
bytea做substring会先把值拉出来(可能触发 TOAST 解压和 detoast),代价随对象大小增长。
这正是大对象存在的根本原因:大对象的"页"是显式的、可寻址的;TOAST 的"chunk"是隐式的、不可寻址的。
CREATE TABLE t_bytea (
id serial PRIMARY KEY,
raw bytea
);
-- bytea 列默认是 EXTENDED
ALTER TABLE t_bytea ALTER COLUMN raw SET STORAGE EXTERNAL;如果二进制内容已经被压缩过(例如 JPEG、ZIP、MP4),压缩是白费 CPU,可以显式改成 EXTERNAL 跳过压缩尝试。
-- 观察 TOAST 带来的存储开销(stored_bytes < logical_bytes 说明发生了压缩)
SELECT id,
octet_length(raw) AS logical_bytes,
pg_column_size(raw) AS stored_bytes
FROM t_bytea;stored_bytes < logical_bytes 说明发生了压缩;如果两者接近而 stored_bytes 略大,说明只做了 TOAST 外存没有压缩收益。
语法与用法
bytea 的两种输入输出格式
bytea 有两种文本表示格式,由参数 bytea_output 决定输出格式(默认 hex):
| 格式 | 外观 | 说明 |
|---|---|---|
hex | \x48656c6c6f | 十六进制,自 PostgreSQL 9.0 起为默认 |
escape | Hello / \\001 | 八进制转义,老格式 |
两种格式在输入时都被接受,但只在必要时使用 escape:
-- hex 输入(推荐),输出 \x48656c6c6f
SELECT '\x48656c6c6f'::bytea;
-- escape 输入(老式,仅在需要时用)
SELECT E'\\101\\102\\103'::bytea;escape 格式的输出规则比较绕(可打印字符原样输出,反斜杠本身要写成 \\,其他字节写成 \ooo 八进制),容易出错。建议始终使用 hex 格式。
decode() 与 encode()
从字符串生成 bytea、把 bytea 转回字符串,用 decode() / encode():
-- 字符串 → bytea
SELECT decode('48656c6c6f', 'hex');
SELECT decode('SGVsbG8=', 'base64');
SELECT decode('Hello', 'escape');-- bytea → 字符串(输出如 48656c6c6f)
SELECT encode('\x48656c6c6f'::bytea, 'hex');
SELECT encode('\x48656c6c6f'::bytea, 'base64');
SELECT encode('\x48656c6c6f'::bytea, 'escape');支持的格式:base64、escape、hex。注意 decode() 的输出总是 bytea,而 encode() 的输出总是 text——这是两个函数唯一的方向性区别,但方向容易记反(decode 是"把文本解码成二进制",encode 是"把二进制编码成文本")。
与 bytea 相关的常用函数
| 函数 | 签名 | 说明 |
|---|---|---|
octet_length() | octet_length(bytea) → integer | 字节数(不触发 detoast 的估算,实际会返回精确长度) |
length() | length(bytea) → integer | 对 bytea 等价于 octet_length |
substring() | substring(bytea from int for int) → bytea | 取子串 |
get_byte() | get_byte(bytea, int) → integer | 取某个字节的值(0~255) |
set_byte() | set_byte(bytea, int, int) → bytea | 设置某个字节,返回新值 |
get_bit() | get_bit(bytea, int) → integer | 取某一位 |
set_bit() | set_bit(bytea, int, int) → bytea | 设置某一位,返回新值 |
md5() | md5(bytea) → text | MD5 摘要 |
sha256() | sha256(bytea) → bytea | SHA-256 摘要 |
convert_to() | convert_to(text, encoding) → bytea | 文本按指定编码转字节 |
convert_from() | convert_from(bytea, encoding) → text | 字节按指定编码转文本 |
需要特别注意 set_byte() 和 set_bit() 的语义:它们不是原地修改,而是返回一个新的 bytea。要真正生效,必须把返回值写回列:
-- 错误:丢弃了返回值
SELECT set_byte(raw, 0, 255) FROM t_bytea WHERE id = 1;
-- 正确:写回
UPDATE t_bytea SET raw = set_byte(raw, 0, 255) WHERE id = 1;这正是 bytea 做局部修改昂贵的体现:哪怕只改 1 个字节,也要"读出整值 → 生成新值 → 写回整值",对于 500 MB 的 bytea,这个操作代价极高。而大对象的 lo_put 只改一页。
互转:bytea ↔ 大对象
PostgreSQL 提供了两条方向的转换:
-- bytea → 大对象;大对象 → bytea(返回 \xdeadbeef)
SELECT lo_from_bytea(0, '\xdeadbeef') AS loid;
SELECT lo_get(16389);迁移 bytea 到 LO:
-- 假设已有 t_bytea(id, raw),要迁移到 LO 形式
CREATE TABLE t_lo (
id integer PRIMARY KEY,
loid oid
);
INSERT INTO t_lo (id, loid)
SELECT id, lo_from_bytea(0, raw)
FROM t_bytea;迁移 LO 回 bytea(只在对象小于 1 GB 时可行):
CREATE TABLE t_bytea (
id integer PRIMARY KEY,
raw bytea
);
INSERT INTO t_bytea (id, raw)
SELECT id, lo_get(loid)
FROM t_lo;如果某个大对象超过 1 GB,这一步会失败(ERROR: invalid memory alloc request size ...)。
大对象 → bytea 的方向有损(受 1 GB 上限约束),bytea → 大对象的方向是无损的。迁移前务必先统计最大对象尺寸:
SELECT max((max(pageno) + 1) * 2048) AS max_logical_bytes
FROM pg_largeobject
GROUP BY loid
ORDER BY max_logical_bytes DESC
LIMIT 1;备份与恢复的差异
| 工具 / 选项 | 对大对象的处理 |
|---|---|
pg_dump(默认) | 包含大对象 |
pg_dump -b / --large-objects | 显式包含(--blobs 为已废弃别名) |
pg_dump -B / --no-large-objects | 显式排除(--no-blobs 为已废弃别名) |
pg_dump -n schema | 不导出大对象 |
pg_dump --data-only | 包含大对象(大对象被视为数据) |
pg_dump --schema-only | 不包含大对象 |
pg_dump 把大对象视为数据(data)而非 schema 对象,因此:
--data-only时导出;--schema-only时不导出;- 但只要指定了
-n(限定 schema),大对象就不会被导出——因为大对象不属于任何 schema。
而 bytea 是普通列,跟着表走,-n 限定 schema 时会正常导出。
# 只导数据,包含大对象
pg_dump --data-only mydb > data.sql
# 限定 schema —— 大对象会丢失!
pg_dump -n public mydb > public_only.sql这是 bytea 在备份维度上的一个实在优势:它天然跟着表的 schema 走,不会因为备份范围参数而"神秘消失"。 如果必须用大对象,备份脚本要特别留意不要用 -n。
逻辑复制的差异
这是最硬的一条差异。官方文档在"逻辑复制的限制"中明确写道:
Large objects are not replicated. There is no workaround for that, other than storing data in normal tables.
翻译过来:大对象不被复制,且没有变通方案,唯一办法是把数据存进普通表。
逻辑复制(publication / subscription)
├── 表数据 ✅ 复制
├── 序列(PG 10+) ✅ 复制
├── 大对象 ❌ 不复制,无变通方案
└── bytea 列 ✅ 随表一起复制这意味着:如果你的架构需要逻辑复制、逻辑订阅、或者基于逻辑解码的 CDC,那么大对象根本不可用。 这是选型时的一票否决项,必须先确认。
需要注意的是,这与流复制(物理复制)不同:物理复制基于 WAL,大对象的所有变更都会随 WAL 复制到备库。所以主备高可用场景下大对象没问题,只有逻辑复制不行。
详细说明
全面对比表
把各维度集中到一张表:
| 维度 | bytea | 大对象(LO) |
|---|---|---|
| 单值上限 | 1 GB(2³⁰−1) | 约 4 TB |
| 存储介质 | 表堆文件 / TOAST 表 | 系统表 pg_largeobject |
| 寻址粒度 | 整值(TOAST 不可寻址) | 2 kB 逻辑页,按偏移可寻址 |
| 流式读写 | 不支持,整值进出 | 支持,loread / lowrite 分块 |
| 随机访问 | 差(需 detoast 整值) | 好(索引定位到页) |
| 局部修改代价 | 高(整值重写) | 低(只改涉及的页) |
| 事务开销 | 低(随行事务) | 中(描述符 + 额外系统表操作) |
| 生命周期 | 随行自动删除 | 独立,需 lo_unlink / vacuumlo |
| 权限粒度 | 列级 GRANT | 对象级 GRANT ON LARGE OBJECT |
| 备份 | 随表导出,受 -n 保护 | 视为数据,-n 时丢失 |
| 流复制 | 支持 | 支持(走 WAL) |
| 逻辑复制 | 支持 | 不支持,无变通方案 |
| 客户端支持 | 所有语言原生支持 | 需专门 API(libpq / JDBC / PHP pgsql) |
| 使用复杂度 | 低 | 高 |
| 内存占用 | 整值载入内存 | 可按分块控制 |
内存与并发表现
bytea 的读写是整值语义,这意味着一次 SELECT raw FROM t WHERE id = 1 会把整个值载入内存(服务端 detoast + 客户端接收)。当值达到几百 MB 时:
- 服务端需要临时分配同等大小的内存;
work_mem相关的排序、哈希操作会受影响(大值可能溢出到磁盘);- 客户端需要同样大的缓冲区;
- 网络传输是一次性的、不可中断的大块。
大对象则天然分块:
BEGIN;
SELECT lo_open(16391, 262144) AS fd \gset
-- 每次读 64 kB,内存峰值恒定
SELECT loread(:fd, 65536);
SELECT lo_close(:fd);
COMMIT;无论是 10 MB 还是 100 GB 的对象,内存峰值都只和单次读取的块大小有关。 这是大对象在高并发、大对象场景下的结构性优势。
权限粒度
bytea 走的是列级权限:
-- 整列授权
GRANT SELECT (raw) ON t_bytea TO reader;
GRANT UPDATE (raw) ON t_bytea TO writer;大对象走的是对象级权限:
-- 针对具体的大对象授权
GRANT SELECT ON LARGE OBJECT 16391 TO reader;
GRANT UPDATE ON LARGE OBJECT 16391 TO writer;两者各有优劣:
bytea:粒度是列,一行内的所有值共享同一套权限。适合"整列都是同一类数据"。- 大对象:粒度是单个对象,可以只授权某一个文件。适合"文件是独立的资源,权限按文件走"。
但大对象的对象级授权意味着每新增一个对象就要授权一次(或依赖属主身份),在高频写入场景下会成为负担。
客户端支持
bytea 在所有语言里都是一等公民:
| 语言 | bytea | 大对象 |
|---|---|---|
| PHP(pgsql) | 直接以字符串传递 | pg_lo_* 系列函数 |
| PHP(PDO_PGSQL) | 以字符串/Lob 传递 | 无专用 API,需 SQL 层函数 |
| Python(psycopg2) | memoryview / bytes | connection.lobject() 工厂 |
| Java(JDBC) | byte[] 或 setBinaryStream() | LargeObjectManager |
| Go(lib/pq / pgx) | []byte | 无专用 API,需 SQL 层函数 |
PDO_PGSQL 没有大对象的专用 API——这是很多 PHP 项目的实际约束。在 PDO 下要做大对象,只能用 SQL 层的 lo_create / lo_unlink,读写则无法流式(PDO 的 PDO::PARAM_LOB 配合 bytea 才是 PDO 的推荐路径)。
决策清单
按下面的顺序逐条判断:
1. 单个对象是否超过 1 GB?
├── 是 → 必须用大对象
└── 否 → 继续
2. 是否需要逻辑复制 / 逻辑订阅 / CDC?
├── 是 → 必须用 bytea(大对象不支持)
└── 否 → 继续
3. 是否需要频繁读写对象内部的局部片段?
├── 是 → 大对象更合适
└── 否 → 继续
4. 是否需要流式处理以避免内存峰值?
├── 是 → 大对象更合适
└── 否 → 继续
5. 其他所有情况 → 用 bytea再补一条经验法则:如果对象的典型大小在几百 KB 到几十 MB,且是"整体写入、整体读取"的模式(图片、缩略图、配置文件、小文档),bytea 几乎总是更好的选择。
性能对比的实测思路
要为自己的场景选型,最可靠的方式是实测。下面给出一个可复现的思路。
准备测试数据:
CREATE TABLE bench_bytea (id integer PRIMARY KEY, raw bytea);
CREATE TABLE bench_lo (id integer PRIMARY KEY, loid oid);
-- 各插入 1000 个 1 MB 的对象
INSERT INTO bench_bytea (id, raw)
SELECT g, decode(repeat('ab', 524288), 'hex') -- 1 MB
FROM generate_series(1, 1000) g;
INSERT INTO bench_lo (id, loid)
SELECT g, lo_from_bytea(0, decode(repeat('ab', 524288), 'hex'))
FROM generate_series(1, 1000) g;测整值读取:
-- bytea:整值
EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(octet_length(raw)) FROM bench_bytea;
-- 大对象:只读 1 kB
EXPLAIN (ANALYZE, BUFFERS)
SELECT sum(octet_length(lo_get(loid, 0, 1024))) FROM bench_lo;测局部访问:
-- bytea 取中间 1 kB(会触发 detoast)
EXPLAIN (ANALYZE, BUFFERS)
SELECT substring(raw from 500000 for 1024) FROM bench_bytea WHERE id = 1;
-- 大对象取中间 1 kB(索引定位到页)
EXPLAIN (ANALYZE, BUFFERS)
SELECT lo_get(loid, 500000, 1024) FROM bench_lo WHERE id = 1;观察的指标:
| 指标 | 观察方式 |
|---|---|
| 执行时间 | EXPLAIN (ANALYZE) 的 Execution Time |
| 缓冲区访问 | EXPLAIN (ANALYZE, BUFFERS) 的 shared hit/read |
| 表物理大小 | pg_total_relation_size() |
| 内存峰值 | 客户端监控 / 服务端 pg_stat_activity |
经验性的预期:
- 整值读写小对象(< 10 MB):
bytea通常更快,因为它是一次行访问,而大对象需要lo_open+loread+lo_close三次往返。 - 局部读取大对象(> 100 MB):大对象明显更快,因为它不需要 detoast 整值。
- 存储空间:
bytea会尝试压缩,若数据可压缩则可能占用更少空间;大对象不压缩,但稀疏存储可能省空间。
迁移方案
bytea → 大对象:
BEGIN;
CREATE TABLE files_new (id integer PRIMARY KEY, name text, loid oid);
INSERT INTO files_new (id, name, loid)
SELECT id, name, lo_from_bytea(0, raw)
FROM files_old;
-- 校验数量一致
SELECT (SELECT count(*) FROM files_new) AS new_cnt,
(SELECT count(*) FROM files_old) AS old_cnt;
COMMIT;大对象 → bytea:
BEGIN;
-- 先检查是否有超过 1 GB 的对象,预期返回 0
SELECT count(*) AS too_big
FROM (
SELECT loid, (max(pageno) + 1) * 2048 AS logical_bytes
FROM pg_largeobject
GROUP BY loid
) t
WHERE logical_bytes > 1073741824;确认没有超限对象后再转换:
CREATE TABLE files_back (id integer PRIMARY KEY, name text, raw bytea);
INSERT INTO files_back (id, name, raw)
SELECT id, name, lo_get(loid)
FROM files_new_corrected
WHERE loid IS NOT NULL;
COMMIT;混合方案:小对象用 bytea,大对象用 LO
如果数据尺寸分布很宽,可以按阈值分流:
CREATE TABLE files (
id integer PRIMARY KEY,
name text,
is_lo boolean NOT NULL,
raw bytea, -- is_lo = false 时使用
loid oid, -- is_lo = true 时使用
CONSTRAINT chk_storage CHECK (
(is_lo AND loid IS NOT NULL AND raw IS NULL) OR
(NOT is_lo AND raw IS NOT NULL AND loid IS NULL)
)
);这个设计用一个 CHECK 约束保证"两种存储互斥",代价是查询时需要分支处理。除非数据确实双峰分布,否则不建议引入这种复杂度。
实战示例
PHP:用 bytea 上传与下载
<?php
$conn = pg_connect("host=localhost dbname=mydb user=app");
// 建表
pg_query($conn, "CREATE TABLE IF NOT EXISTS uploads (
id serial PRIMARY KEY,
name text NOT NULL,
raw bytea NOT NULL
)");
// 上传:把客户端本地文件读成字节串,用 bytea 参数传入
$data = file_get_contents('/tmp/upload.png');
if ($data === false) {
throw new RuntimeException('read local file failed');
}
$res = pg_query_params(
$conn,
"INSERT INTO uploads (name, raw) VALUES ($1, $2)",
['upload.png', $data]
);
if ($res === false) {
throw new RuntimeException(pg_last_error($conn));
}
echo "inserted: " . pg_affected_rows($res) . "\n";
// 下载:把 bytea 取出来写到客户端文件
$res = pg_query_params($conn, "SELECT raw FROM uploads WHERE id = $1", [1]);
$row = pg_fetch_assoc($res);
file_put_contents('/tmp/download.png', pg_unescape_bytea($row['raw']));
echo "downloaded\n";
?>注意 pg_unescape_bytea():PHP 的 pgsql 扩展在某些配置下会把 bytea 的值以 escape 格式返回,用这个函数可以还原成原始字节。
对于较大的 bytea,file_get_contents 会一次性把整个文件读进内存;如果文件可能比较大,应改用流式读取的行协议,或者改用大对象路径:
<?php
$conn = pg_connect("host=localhost dbname=mydb user=app");
pg_query($conn, "BEGIN");
$oid = pg_lo_create($conn);
$lob = pg_lo_open($conn, $oid, "w");
// 分块写入,避免整文件载入内存
$fh = fopen('/tmp/big.bin', 'rb');
while (!feof($fh)) {
$chunk = fread($fh, 1024 * 1024);
if ($chunk === false || $chunk === '') {
break;
}
pg_lo_write($lob, $chunk);
}
fclose($fh);
pg_lo_close($lob);
pg_query($conn, "COMMIT");
// 关联到业务表
pg_query_params($conn, "INSERT INTO uploads_lo (name, loid) VALUES ($1, $2)",
['big.bin', $oid]);
?>Python(psycopg2):用 bytea 上传与下载
import psycopg2
conn = psycopg2.connect("dbname=mydb user=app host=localhost")
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS uploads (
id serial PRIMARY KEY,
name text NOT NULL,
raw bytea NOT NULL
)
""")
# 上传:psycopg2 会自动把 bytes 适配为 bytea
with open('/tmp/upload.png', 'rb') as f:
data = f.read()
cur.execute(
"INSERT INTO uploads (name, raw) VALUES (%s, %s)",
('upload.png', psycopg2.Binary(data))
)
conn.commit()
print("inserted id:", cur.lastrowid if hasattr(cur, 'lastrowid') else 'n/a')
# 下载
cur.execute("SELECT raw FROM uploads ORDER BY id DESC LIMIT 1")
raw = cur.fetchone()[0] # 返回 memoryview
with open('/tmp/download.png', 'wb') as f:
f.write(bytes(raw))
conn.close()psycopg2 从 bytea 读取时返回 memoryview,写入 bytes 时用 psycopg2.Binary() 适配(也可以直接传 bytes)。
Python(psycopg2):用大对象流式处理
import psycopg2
conn = psycopg2.connect("dbname=mydb user=app host=localhost")
conn.autocommit = False # 大对象操作必须在事务内
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS uploads_lo (
id serial PRIMARY KEY,
name text NOT NULL,
loid oid NOT NULL
)
""")
# 创建并写入大对象(流式)
lobj = conn.lobject(0, 'wb') # oid=0 表示新建
with open('/tmp/big.bin', 'rb') as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
lobj.write(chunk)
oid = lobj.oid
lobj.close()
cur.execute("INSERT INTO uploads_lo (name, loid) VALUES (%s, %s)",
('big.bin', oid))
conn.commit()
# 读取大对象(流式)
cur.execute("SELECT loid FROM uploads_lo ORDER BY id DESC LIMIT 1")
loid = cur.fetchone()[0]
lobj = conn.lobject(loid, 'rb')
with open('/tmp/big_out.bin', 'wb') as f:
while True:
chunk = lobj.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
lobj.close()
conn.commit()
conn.close()psycopg2 的 lobject() 是 connection 上的工厂方法,返回一个 lobject 实例;mode 用 'rb' / 'wb' / 'rwb' 这样的字符串表示。它还提供了 lo_import() / lo_export()(操作服务器文件系统)。
JDBC:bytea 二进制流
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO uploads (name, raw) VALUES (?, ?)");
ps.setString(1, "upload.png");
InputStream in = new FileInputStream("/tmp/upload.png");
ps.setBinaryStream(2, in);
ps.executeUpdate();
in.close();JDBC 用 setBinaryStream 时,驱动可能会把整个流缓冲到内存再发送,也可能流式发送——具体行为取决于驱动版本和 bytea 值的大小。对于超大文件,JDBC 的 LargeObjectManager 路径更可控。
用 lo_get 做局部读取以节省流量
如果只关心业务行关联的大对象的"前 16 字节",不要用 SELECT lo_get(loid),而应指定长度:
-- 差:读取整个对象
SELECT id, lo_get(loid) FROM files WHERE id = 1;
-- 好:只读文件头
SELECT id, lo_get(loid, 0, 16) AS header FROM files WHERE id = 1; id | header
----+----------------------------------
1 | \x89504e470d0a1a0a0000000d49484452
(1 row)89 50 4E 47 是 PNG 的魔数,说明这是一个 PNG 文件。
用 md5 做完整性校验
无论选哪种存储,都建议存一个校验和:
-- bytea 场景
SELECT md5(raw) AS checksum FROM t_bytea WHERE id = 1;
-- 大对象场景
SELECT md5(lo_get(loid)) AS checksum FROM t_lo WHERE id = 1; checksum
----------------------------------
d41d8cd98f00b204e9800998ecf8427e
(1 row)大对象场景下 md5(lo_get(...)) 需要把整个对象读出来,对超大对象代价高。更好的做法是在应用层流式计算摘要,或者把校验和单独存在业务表的列里:
ALTER TABLE files ADD COLUMN checksum text;注意事项
逻辑复制不支持大对象,这是硬限制
官方原文:"Large objects are not replicated. There is no workaround for that, other than storing data in normal tables." 需要逻辑复制的架构里不要用大对象。
bytea 的 1 GB 上限是类型级限制
不是参数,改配置也没用。超过就必须用大对象。
TOAST 不等于大对象
TOAST 让 bytea 能超过单页大小,但它是透明分片、不可寻址、不突破 1 GB。不要把 TOAST 当成"内置的大对象"。
pg_dump -n 会丢掉大对象
大对象不属于任何 schema,指定 -n 时不会导出。备份脚本要特别留意这一点。
decode() 和 encode() 的方向容易记反
decode(text, format) → bytea;encode(bytea, format) → text。
set_byte() / set_bit() 返回新值,不原地修改
必须把返回值写回列,否则修改丢失。
bytea_output = escape 的兼容性问题
老客户端依赖 escape 格式时,改成 hex 可能导致解析失败。升级前确认客户端兼容性。
不要对已压缩的数据启用压缩
JPEG、ZIP、MP4 等格式压缩率极低,白白消耗 CPU。可以把列改为 STORAGE EXTERNAL。
从大对象转回 bytea 前必须检查尺寸
超过 1 GB 的对象转换会报 invalid memory alloc request size。
lo_get 不带长度会读整个对象
即使对象有 4 TB,加不加 length 决定了内存是否爆掉。
PDO_PGSQL 没有大对象专用 API
用 PDO 的项目要读大对象,只能走 SQL 层函数,且拿不到流式接口。这通常是选 bytea 的理由。
PDO 的 bytea 也要注意转义
PDO 的参数绑定与 pg_escape_bytea 风格不同,混用容易产生损坏的数据。统一走参数绑定。
最佳实践
默认选
bytea。它更简单、支持逻辑复制、随行事务、所有语言原生支持。只有当 1 GB 上限或随机访问需求成为真实瓶颈时才换大对象。先量化对象尺寸分布,再决定选型:
sqlSELECT count(*) AS total, count(*) FILTER (WHERE octet_length(raw) > 10485760) AS over_10mb, pg_size_pretty(max(octet_length(raw))) AS max_size FROM uploads;有逻辑复制需求时,一律用
bytea。这条优先级最高,因为大对象在逻辑复制下无解。二进制内容不要走文本编码往返。用参数绑定传
bytes,不要先 base64 再解码,既浪费空间又容易出错。bytea单值尽量控制在几十 MB 以内。超过后 detoast 的代价会明显体现,考虑改用大对象。对已压缩格式关闭 TOAST 压缩:
sqlALTER TABLE uploads ALTER COLUMN raw SET STORAGE EXTERNAL;为二进制列单独建表,与业务热数据分离。避免每次查询业务行都因为行太大而拉慢:
sqlCREATE TABLE upload_meta (id serial PRIMARY KEY, name text, size_bytes bigint); CREATE TABLE upload_blob (id integer PRIMARY KEY REFERENCES upload_meta(id), raw bytea);用
pg_total_relation_size()监控 TOAST 占用,把它纳入容量规划:sqlSELECT relname, pg_size_pretty(pg_relation_size(oid)) AS main, pg_size_pretty(pg_total_relation_size(reltoastrelid)) AS toast FROM pg_class WHERE relname = 'uploads';选定存储后存一个校验和,独立成列,便于校验与去重(可配合
sha256)。迁移前先在测试环境演练,尤其要验证:尺寸是否超限、备份是否完整、恢复后能否正常读取。
备份脚本要显式验证大对象被包含(若使用大对象)。导入一个备份到临时库,比对
pg_largeobject_metadata的行数是可靠做法:sqlSELECT count(*) FROM pg_largeobject_metadata;混合存储方案仅在数据双峰分布时才使用,并用 CHECK 约束保证两种存储互斥,避免出现"两边都写"或"两边都空"的脏数据。
下一节
参考链接
- PostgreSQL 官方文档:Binary Data Types
- PostgreSQL 官方文档:Binary String Functions and Operators
- PostgreSQL 官方文档:TOAST
- PostgreSQL 官方文档:Large Objects
- PostgreSQL 官方文档:Server-Side Functions for Large Objects
- PostgreSQL 官方文档:Restrictions on Logical Replication
- PostgreSQL 官方文档:pg_dump
- PostgreSQL 官方文档:ALTER TABLE(SET STORAGE)
- PostgreSQL 官方文档:Object Identifier Types
- PHP 手册:PostgreSQL 函数