Skip to content

大对象(Large Objects)概述 ​

在 PostgreSQL 里存一个二进制文件,第一反应往往是 bytea 字段。bytea 简单、直观、跟着表一起走事务,绝大多数场景确实够用。但它有一个绕不开的天花板:单个字段的值最多 1 GB,而且每次读取都会把整个值拉进内存。

当你要存的是一段视频、一个几十 GB 的备份镜像、或者一个需要被反复读取其中一小段的归档文件时,bytea 就不合适了。PostgreSQL 为此提供了另一套机制——大对象(Large Objects,简称 LO)。

大对象把二进制数据拆成固定大小的数据块,存进一张系统表,用一个 4 字节整数(OID)来引用。它单个对象最大可达 4 TB,并且支持按需读写任意片段,不用把整个对象读进内存。本节讲清大对象是什么、底层怎么存、权限与事务怎么管,后续小节展开具体操作函数与 bytea 的选型对比。

前置知识

建议先阅读:自定义扩展开发

基础概念 ​

什么是大对象 ​

大对象是 PostgreSQL 提供的一种在数据库内部存储大规模二进制数据的机制。它不属于任何一张业务表,而是存放在系统目录表里,业务表只保存一个指向它的 OID。

一个典型的用法:photos 表存元数据,真正的图片内容存在大对象里。

sql
CREATE TABLE photos (
    id        serial PRIMARY KEY,
    title     text,
    filename  text,
    raster    oid          -- 这里只存一个 OID,指向真正的大对象
);

-- 从服务器文件系统导入一个文件,返回新的大对象 OID
SELECT lo_import('/tmp/photo.png');
text
 lo_import
-----------
     16385
(1 row)

把 16385 写进 photos.raster,图片的二进制内容就"存进数据库"了。业务表只增长了一个 4 字节整数,实际数据在系统表里分块存放。

大对象 vs 普通字段:本质区别 ​

大对象和普通表字段最大的差异不在于"容量大",而在于存储位置与访问方式:

维度普通字段(含 bytea、text)大对象(LO)
存储位置表的堆文件(超大值走 TOAST)独立系统表 pg_largeobject
值的整体性存进去就是一个完整值拆成多个逻辑页分块存储
单值上限1 GB约 4 TB
读取方式一次性读全值可按偏移、按长度读取片段
修改方式整值重写(或 TOAST 部分更新)可就地按偏移写入片段
生命周期随行删除而消失独立存在,需显式删除
引用方式直接是列的值通过 OID 间接引用

最后两条是实践中最容易踩坑的地方:大对象的生命周期与业务行解耦。删掉 photos 表里的一行,raster 指向的大对象不会被自动删除,它会变成"孤儿对象",一直占用空间,直到你显式调用 lo_unlink() 或运行 vacuumlo。

关键的四种 OID 相关概念 ​

大对象的引用都用 OID,围绕它有几个容易混淆的概念,先厘清:

概念类型说明
OID(Object Identifier)oid无符号 4 字节整数,全库范围内唯一标识一个对象
大对象 OID(loid)oid大对象在 pg_largeobject 中的标识,即 loid 列
oid 类型oid内置的 OID 类型,业务表通常用 oid 列存大对象引用
lo 类型lo由 lo 扩展提供的类型,本质是 oid 的一个 domain

关于 lo 类型,需要澄清一个常见误解:lo 不是内置数据类型,PostgreSQL 的 OID 别名类型表(官方文档 Table 8.26)里并没有它。它由 contrib/lo 扩展提供,是一个建立在 oid 之上的 domain。这个扩展目前依然存在于 PostgreSQL 18 的官方文档中(附录 F.21),并未被移除。它的价值在于能额外挂一个触发器来自动清理大对象,详见后文。

存储结构:pg_largeobject 与分块 ​

大对象的实际数据放在系统目录表 pg_largeobject 里,这张表只有三列:

列名类型说明
loidoid该行所属的大对象的 OID
pagenoint4页号,从 0 开始
databytea这一页上承载的数据

每个大对象的数据被切成固定大小的逻辑页,每页单独占 pg_largeobject 的一行。虽然这个表叫"表",但它不是普通堆表,而是用 B-tree 索引组织的,索引键是 (loid, pageno)。这个设计让"读取第 N 页"变成一次索引定位,从而支撑随机访问。

页的大小由常量 LOBLKSIZE 决定,它的定义是:

text
LOBLKSIZE = BLCKSZ / 4

BLCKSZ 是数据库块大小,默认 8 kB,因此 LOBLKSIZE 的默认值是 2 kB。数据中偏移量为 offset 的字节,位于大对象的第 offset / LOBLKSIZE 页(整除),页内偏移为 offset % LOBLKSIZE。

sql
-- 查看当前实例的块大小相关参数
SHOW block_size;
text
 block_size
------------
 8192
(1 row)

因为 LOBLKSIZE = BLCKSZ / 4,默认配置下 LOBLKSIZE 就是 2048 字节。这意味着一个 1 MB 的文件会占用约 512 行 pg_largeobject 记录。

稀疏存储:不存在的页读出零 ​

pg_largeobject 允许页号不连续。如果某个页从来没有被写过,它在表里就没有对应的行;当读取操作落在这些"空洞"上时,PostgreSQL 返回的是零字节,而不是报错。

这个行为非常像 Unix 文件系统里的稀疏文件(sparse file):你可以在一个空文件的末尾写入 1 MB 处,只占用实际写入的数据块,中间的空洞在读取时表现为零。

sql
-- 创建一个新的大对象,直接从偏移 1MB 处写入 3 个字节
SELECT lo_put(lo_create(0), 1048576, '\x010203');

无需先分配 1 MB,PostgreSQL 只会为真正写入的那一页分配空间。这个特性让大对象适合做"随机位置写入"的场景,比如往一个归档文件里追加或修补某一段。但要小心:无法区分"已写入的零"和"从未写入的零",读到的都是零字节。

元数据:pg_largeobject_metadata ​

除了数据本身,每个大对象还有一份元数据,存在 pg_largeobject_metadata 表里:

列名类型说明
oidoid该元数据行的标识,即大对象的 OID
lomowneroid大对象的属主,引用 pg_authid.oid
lomaclaclitem[]访问权限列表(ACL)

这张表的存在有历史原因。在 PostgreSQL 9.0 之前,pg_largeobject 是公开可读的,任何用户只要知道 OID 就能读到内容,而且大对象没有属主、没有权限控制。9.0 之后引入了属主与 ACL,把它们放到 pg_largeobject_metadata,同时把 pg_largeobject 限制为只有超级用户可读。

因此,要查看大对象的属主与权限,必须查询 pg_largeobject_metadata,而不是 pg_largeobject。

sql
-- 查看所有大对象及其属主
SELECT oid AS loid,
       lomowner::regrole AS owner,
       lomacl
FROM pg_largeobject_metadata
ORDER BY oid;
text
  loid |   owner   | lomacl
-------+-----------+---------
 16385 | app_owner |
 16386 | app_owner | {app_user=rw/app_owner}
(2 rows)

lomacl 为 NULL 表示使用默认权限(只有属主可操作);被 GRANT 过则显示为具体的 aclitem 数组。

语法与用法 ​

创建与删除 ​

大对象的创建与删除通过服务端函数完成,核心是三个:

sql
-- 创建一个新的大对象,返回其 OID
-- 参数为该对象的 OID;传 0 表示自动分配
SELECT lo_create(0);

-- 旧式别名,与 lo_create 等价;其 mode 参数自 8.1 起被忽略
SELECT lo_creat(-1);

-- 删除一个大对象
SELECT lo_unlink(16385);
text
 lo_create
-----------
     16387
(1 row)

注意 lo_creat 是旧别名,只是"少一个 e",很容易与 lo_create 混淆。两者功能相同,但 lo_creat 的第二个参数(曾经的访问模式)自 PostgreSQL 8.1 起已被完全忽略,写什么值都没有影响。生产代码应当使用 lo_create。更详细的函数说明见下一节。

读写的最小示例 ​

服务端的读写函数是 loread / lowrite(注意:服务端函数名没有下划线,与客户端 libpq 的 lo_read / lo_write 不同)。这两个函数作用于一个"大对象描述符",描述符由 lo_open 打开、lo_close 关闭。

sql
BEGIN;

-- 打开大对象,返回描述符
SELECT lo_open(16385, x'00060000'::int);  -- INV_READ | INV_WRITE

-- 写入数据
SELECT lowrite(0, '\x48656c6c6f');

-- 定位到开头再读
SELECT lo_lseek(0, 0, 0);
SELECT loread(0, 5);

-- 关闭描述符
SELECT lo_close(0);

COMMIT;
text
 loread
--------
 \x48656c6c6f
(1 row)

这个例子里出现的整数常量 x'00020000'::int 并不直观——它就是下面要讲的 INV_READ / INV_WRITE 组合。实际使用中,建议用它们的数值常量或直接在客户端接口里用符号名。

权限授予 ​

大对象支持与表类似的 GRANT / REVOKE,语法里的对象类型是 LARGE OBJECT:

sql
GRANT SELECT ON LARGE OBJECT 16385 TO app_user;
GRANT UPDATE ON LARGE OBJECT 16385 TO app_user;
GRANT ALL PRIVILEGES ON LARGE OBJECT 16385 TO app_owner;

REVOKE SELECT ON LARGE OBJECT 16385 FROM app_user;

可授予的权限只有两种:SELECT(读)和 UPDATE(写、截断)。删除、修改属主、添加注释不能通过 GRANT 授予,只有属主或超级用户才能做。

详细说明 ​

大对象权限模型 ​

大对象的权限模型与表高度一致,但粒度更粗——只有读写两档。

操作需要的权限
读取内容(loread / lo_get)SELECT
写入、修改内容(lowrite / lo_put / lo_truncate)UPDATE
删除对象(lo_unlink)属主或超级用户
修改属主(ALTER LARGE OBJECT ... OWNER TO)属主或超级用户
添加注释(COMMENT ON LARGE OBJECT)属主或超级用户
授予权限(GRANT ON LARGE OBJECT)属主或超级用户

一个容易被忽略的细节:权限检查发生在 lo_open 打开大对象的那一刻。这是 PostgreSQL 11 的行为变更——在此之前,检查推迟到第一次实际读写调用。这个变化意味着,即使用户打开后什么都没做,权限不足也会在 lo_open 阶段直接失败。

sql
-- 以无权限的用户打开一个大对象
SELECT lo_open(16385, 262144);  -- INV_READ
text
ERROR:  permission denied for large object 16385

lo_compat_privileges 参数 ​

为了兼容 PostgreSQL 9.0 之前"大对象无权限控制"的旧行为,系统提供了一个 lo_compat_privileges 参数:

sql
SHOW lo_compat_privileges;
text
 lo_compat_privileges
----------------------
 off
(1 row)

它默认为 off,即启用 9.0 引入的权限检查。只有为了兼容老应用时才考虑打开,且只有超级用户和拥有 SET 权限的角色才能修改。生产环境不应开启——打开它等于让所有大对象对所有用户可读可写。

事务语义:必须在事务内操作 ​

大对象操作有一个硬性要求:必须在事务块内执行。这不是建议,而是设计约束。

原因在于"大对象描述符"的生命周期:lo_open 返回的描述符只在当前事务内有效,事务结束(提交或回滚)时自动关闭。这样设计的目的是保证一致性——大对象的读写与普通表数据共享同一个事务视图。

sql
-- 正确:包在事务里
BEGIN;
SELECT lo_open(16385, 131072);
SELECT loread(0, 100);
SELECT lo_close(0);
COMMIT;
sql
-- 错误:事务外直接操作
SELECT lo_create(0);
text
ERROR:  invalid large-object descriptor: 0

更严格的场景是只读事务:如果一个事务声明为只读(SET TRANSACTION READ ONLY 或 BEGIN READ ONLY),那么大对象写入会被拒绝。

sql
BEGIN READ ONLY;
SELECT lo_open(16385, 262144);  -- INV_READ,只读打开,允许
SELECT lowrite(0, '\x00');      -- 写入,禁止
text
ERROR:  cannot write to large object 16385 in a read-only transaction

INV_READ 与 INV_WRITE:两个模式常量 ​

打开大对象时需要指定访问模式,由两个位常量组合而成,定义在客户端头文件 libpq/libpq-fs.h 中:

常量值(十六进制)含义
INV_READ0x00040000允许读取
INV_WRITE0x00020000允许写入

只读打开用 INV_READ,读写打开用 INV_READ | INV_WRITE。这两个值是位标志(bitmask),可以按位或组合。

这两个模式对读取行为的影响还有一个微妙之处:以 INV_READ 打开时,读取的可见性遵循更严格的快照语义,类比 REPEATABLE READ——同一个事务内多次读取看到一致的快照;而带 INV_WRITE 打开时,类似于 READ COMMITTED,可能看到事务期间其他已提交事务的修改。这个差异在实际中不常被注意到,但在并发写同一个大对象的场景下会有影响。

服务端函数与客户端函数的分工 ​

初学大对象最容易踩的坑,就是把服务端函数与客户端函数混为一谈。它们名字相似,但运行位置和调用方式完全不同:

层次函数名风格调用方式代表函数
服务端(SQL 层)lo_ 开头 + 无下划线动词SQL 语句loread、lowrite、lo_lseek
客户端(libpq)lo_ 开头 + 有下划线动词C 函数调用lo_read、lo_write、lo_lseek

注意 lo_lseek 和 lo_close、lo_open 在两侧同名,而读写的动词一侧无下划线(loread/lowrite)、一侧有下划线(lo_read/lo_write)。写代码时务必确认自己在哪一层。

text
服务端(SQL)       客户端(libpq C API)
loread()      ←→   lo_read()
lowrite()     ←→   lo_write()
lo_lseek()    ←→   lo_lseek()
lo_open()     ←→   lo_open()
lo_close()    ←→   lo_close()

文件导入导出与 pg_read_server_files ​

lo_import / lo_export 这一对函数比较特殊:它们在服务端执行,读写的是数据库服务器所在主机的文件系统,而不是客户端主机的文件系统。

sql
-- 把服务器上的 /etc/motd 导入成一个大对象
SELECT lo_import('/etc/motd');
text
 lo_import
-----------
     16388
(1 row)
sql
-- 把大对象 16388 导出到服务器的 /tmp/motd
SELECT lo_export(16388, '/tmp/motd');

正因为这意味着"能读写服务器文件系统",默认只有超级用户能调用它们。如果希望普通用户也能用,需要通过预定义角色授权:

预定义角色能做什么
pg_read_server_files读取服务器上的任意文件(含 lo_import)
pg_write_server_files写入服务器上的任意文件(含 lo_export)
pg_execute_server_program执行服务器上的程序(COPY ... PROGRAM)
sql
GRANT pg_read_server_files TO app_importer;
GRANT pg_write_server_files TO app_exporter;

授予这三个角色要极其慎重——它们绕过了操作系统的权限体系,等价于把服务器文件系统的读写能力交给了该角色。

大对象与 bytea 的互转 ​

PostgreSQL 提供了两个函数,让大对象和 bytea 之间可以互相转换,这在数据迁移时非常有用:

sql
-- bytea → 大对象:把一段字节写入大对象(loid 传 0 表示新建)
SELECT lo_from_bytea(0, '\xdeadbeef');

-- 大对象 → bytea:读取整个大对象
SELECT lo_get(16389);
text
 lo_from_bytea
---------------
         16389
(1 row)

 lo_get
--------
 \xdeadbeef
(1 row)

lo_get 还能只读一部分,这对于"只取文件头部做格式识别"之类的需求很实用:

sql
-- 只读前 3 个字节
SELECT lo_get(16389, 0, 3);
text
 lo_get
--------
 \xdead
(1 row)

自动清理:lo 扩展与 lo_manage 触发器 ​

如前所述,大对象最大的实践痛点是孤儿对象:业务行删了,大对象还在。手动调用 lo_unlink 容易遗漏,因此 lo 扩展提供了一个触发器函数 lo_manage 来自动化这件事。

这个扩展提供两样东西:

  1. 一个名为 lo 的数据类型(本质是 oid 的 domain),让业务列在语义上更明确;
  2. 一个触发器函数 lo_manage,在行更新或删除时自动清理旧的大对象。

典型用法:

sql
CREATE EXTENSION lo;

CREATE TABLE image (
    title  text,
    raster lo          -- 用 lo 类型代替 oid
);

CREATE TRIGGER t_raster
    BEFORE UPDATE OR DELETE ON image
    FOR EACH ROW EXECUTE FUNCTION lo_manage(raster);

挂上触发器后,删除 image 中的一行,raster 指向的大对象会被自动 lo_unlink;更新 raster 为另一个 OID 时,旧的也会被清理。

但它并不能覆盖所有情况。官方文档明确指出:lo_manage 触发器是 BEFORE UPDATE OR DELETE 级别的,无法感知 DROP TABLE 和 TRUNCATE——这两类操作直接丢弃整表内容,不会触发逐行的 delete 触发器,仍然会留下孤儿对象。所以即使使用了 lo 扩展,仍然建议定期运行 vacuumlo 做兜底扫描。

孤儿对象清理:vacuumlo ​

vacuumlo 是 PostgreSQL 自带的一个命令行工具,专门用来扫描并删除孤儿大对象。它的工作原理是:遍历所有类型为 oid 或 lo 的列,收集其中出现过的所有 OID,然后把 pg_largeobject_metadata 里没有被任何列引用的大对象删掉。

bash
# 用 dry-run 模式先看看会删掉什么(不会真的删)
vacuumlo -n -v postgresql://app@localhost/mydb
text
Connected to database "mydb"
Would remove large object 16385
...

确认无误后去掉 -n 正式执行:

bash
vacuumlo -v postgresql://app@localhost/mydb

常用选项:

选项含义
-l limit每个事务最多处理多少个大对象,默认 1000
-ndry-run,只报告不删除
-v输出详细过程
-V打印版本
-?显示帮助

需要注意两点:一是它只识别类型为 oid 或 lo 的列,如果大对象 OID 被存在 text 或 bigint 列里,vacuumlo 会误判为孤儿并删除;二是它不做域名展开,用 domain 包装过的列也不会被识别。

版本差异一览 ​

大对象机制经历过几次重要演进,了解这些有助于理解为什么文档里有些描述看起来"自相矛盾":

版本变化
8.1lo_creat 的 mode 参数被忽略
8.3libpq 新增 lo_truncate
8.4libpq 新增 lo_import_with_oid
9.0引入属主与 ACL,lomacl 迁入 pg_largeobject_metadata;pg_largeobject 限制为超级用户可读;bytea 的 hex 输出格式引入
9.3libpq 新增 lo_lseek64 / lo_tell64 / lo_truncate64,支持超过 2 GB 的偏移
11权限检查从首次读写调用移至 lo_open

其中最关键的是 9.0 的变化:在此之前大对象完全无权限控制,如果你在网上看到"任何用户都能读大对象"的说法,那是 9.0 之前的旧行为。

适用与不适用场景 ​

大对象不是"更高级的 bytea",它有明确的适用边界:

适合用大对象的场景:

  • 单个对象超过 1 GB,bytea 存不下;
  • 需要随机读取或写入局部内容,例如只读文件头、只在某位置打补丁;
  • 需要流式处理,不希望把几十 GB 内容一次性读进内存;
  • 数据本身就是"文件",与业务行是松散的引用关系。

不适合用大对象的场景:

  • 对象较小(几百 KB 以内),bytea 更简单且能跟着行一起事务管理;
  • 需要逻辑复制——大对象不被逻辑复制,官方明确说明没有变通方案;
  • 需要 pg_dump 按 schema 精确导出——指定 -n 时大对象不会被导出;
  • 希望删除业务行时自动连带删除数据(bytea 天然如此,大对象需要触发器或 vacuumlo)。

实战示例 ​

完整流程:导入、挂载、读取、清理 ​

下面走一遍完整的生命周期,用 psql 演示。

sql
-- 1) 建表,用 oid 列存引用
CREATE TABLE documents (
    id       serial PRIMARY KEY,
    name     text NOT NULL,
    content  oid
);

-- 2) 从服务器导入一个文件
SELECT lo_import('/tmp/report.pdf') AS loid;
text
  loid
--------
 16390
(1 row)
sql
-- 3) 把 OID 挂到业务行上
INSERT INTO documents (name, content)
VALUES ('季度报告.pdf', 16390);
text
INSERT 0 1
sql
-- 4) 读取大对象的前 16 个字节(例如判断文件类型)
SELECT lo_get(d.content, 0, 16) AS header
FROM documents d
WHERE d.name = '季度报告.pdf';
text
                header
--------------------------------------
 \x255044462d312e370a...
(1 row)

25 50 44 46 是 %PDF 的十六进制,说明确实是一个 PDF 文件。

sql
-- 5) 删除业务行,大对象不会自动消失
DELETE FROM documents WHERE name = '季度报告.pdf';

-- 6) 确认大对象仍然存在
SELECT count(*) FROM pg_largeobject_metadata WHERE oid = 16390;
text
 count
-------
     1
(1 row)
sql
-- 7) 显式清理
SELECT lo_unlink(16390);
text
 lo_unlink
-----------
         1
(1 row)

这个例子把"孤儿对象"的成因演示得很清楚:第 5 步删除业务行后,第 6 步仍能查到大对象。这也是为什么推荐用 lo 扩展的 lo_manage 触发器或定期跑 vacuumlo。

用 lo 扩展自动化清理 ​

把上面的流程改造成"自动清理"版本:

sql
CREATE EXTENSION lo;

CREATE TABLE documents (
    id       serial PRIMARY KEY,
    name     text NOT NULL,
    content  lo
);

CREATE TRIGGER t_content
    BEFORE UPDATE OR DELETE ON documents
    FOR EACH ROW EXECUTE FUNCTION lo_manage(content);

INSERT INTO documents (name, content)
VALUES ('季度报告.pdf', lo_import('/tmp/report.pdf'));

-- 删除行时,触发器自动调用 lo_unlink
DELETE FROM documents WHERE name = '季度报告.pdf';
text
DELETE 0 1

此后 pg_largeobject_metadata 中对应的记录会自动消失。

查询所有大对象及其大小 ​

pg_largeobject 的每一行是一页,把某个 loid 的所有页长度相加,就是大对象的近似大小:

sql
SELECT loid,
       count(*)                      AS pages,
       sum(length(data))             AS total_bytes,
       pg_size_pretty(sum(length(data))) AS size
FROM pg_largeobject
GROUP BY loid
ORDER BY total_bytes DESC
LIMIT 10;
text
  loid | pages | total_bytes |  size
-------+-------+-------------+--------
 16391 |  5120 |    10485760 | 10 MB
 16390 |   512 |     1048576 | 1024 kB
(2 rows)

注意这里统计的是实际写入的字节数。因为稀疏存储,如果大对象中间有大段空洞,sum(length(data)) 会小于 (max(pageno) + 1) * LOBLKSIZE,即逻辑大小与物理占用不同。

sql
-- 逻辑大小(按最大页号推算)
SELECT loid,
       (max(pageno) + 1) * 2048 AS logical_bytes
FROM pg_largeobject
GROUP BY loid;

查看权限并授予只读 ​

sql
-- 查看大对象属主与 ACL
SELECT oid, lomowner::regrole AS owner, lomacl
FROM pg_largeobject_metadata
WHERE oid = 16391;
text
  oid  |   owner   | lomacl
-------+-----------+---------
 16391 | app_owner |
(1 row)
sql
-- 授予只读权限
GRANT SELECT ON LARGE OBJECT 16391 TO report_reader;

-- 再查一次,ACL 已经变化
SELECT oid, lomacl FROM pg_largeobject_metadata WHERE oid = 16391;
text
  oid  |            lomacl
-------+-------------------------------
 16391 | {report_reader=r/app_owner}
(1 row)

report_reader=r/app_owner 表示:report_reader 拥有 r(SELECT,即读)权限,由 app_owner 授予。

定位与分片读取 ​

大对象的核心优势是分片读取。下面演示如何只读出一个大对象中间的一段:

sql
BEGIN;

-- 打开(只读模式),描述符为 0
SELECT lo_open(16391, 262144);  -- INV_READ

-- 定位到偏移 1024
SELECT lo_lseek(0, 1024, 0);

-- 读取 32 字节
SELECT loread(0, 32);

-- 查看当前偏移
SELECT lo_tell(0);

SELECT lo_close(0);
COMMIT;
text
 lo_tell
---------
    1056
(1 row)

读完 32 字节后,偏移从 1024 推进到 1056,说明 loread 会移动内部指针。

注意事项 ​

大对象不是自动垃圾回收的

删除业务行不会删除大对象,DROP TABLE 和 TRUNCATE 也不会触发 lo_manage。定期 vacuumlo 是必要的运维动作。这一点与 bytea 的体验差距最大。

逻辑复制不复制大对象

如果需要逻辑复制,不要用大对象存业务数据。官方文档明确写道:大对象不被复制,且没有变通方案,只能把数据存进普通表。这是选型时的硬性约束。

text
逻辑复制包含:表数据、序列(PG 10+)、DDL(部分)
逻辑复制不含:大对象

lo_creat 是旧别名

写代码时统一用 lo_create。lo_creat 的 mode 参数自 8.1 起被忽略,容易误导读者以为它还能控制访问模式。

服务端与客户端函数名容易混淆

loread / lowrite(服务端)与 lo_read / lo_write(libpq 客户端)差一个下划线。看文档或报错时先确认是哪一层。

lo_import / lo_export 动的是服务器文件系统

它们不读客户端本地的文件。要用它们处理客户端文件,得先把文件放到服务器上(或用 psql 的 \lo_import,它是在客户端发起、把文件内容通过协议送到服务器)。

不要开启 lo_compat_privileges

打开它等于关闭大对象权限检查,任何用户都能读写任意大对象。除非在做 9.0 之前老应用的迁移兼容,否则保持 off。

vacuumlo 会误删非标准存储的引用

如果 OID 被存在 text、bigint 或某个 domain 列里,vacuumlo 认不出来,会把这些大对象当孤儿删掉。用 -n 先 dry-run 是必要的。

事务边界要正确

大对象描述符只在当前事务内有效,事务一结束就失效。在连接池里尤其要小心:如果事务被意外提交,后续的大对象操作会直接报 invalid large-object descriptor。

权限检查在 lo_open 阶段

PostgreSQL 11 之后,权限不足会在打开时立即报错,而不是等到第一次读写。写错误处理逻辑时要按这个时机来。

稀疏空洞读出来是零

不要指望能区分"写入的零"与"未写入的零",读操作都返回零字节。依赖长度判断的地方要自己记录实际数据长度。

最佳实践 ​

  1. 先用 bytea,确认不够用再上大对象。对象小于 1 GB 且不需要随机访问时,bytea 更简单,且天然参与事务与逻辑复制。

  2. 业务表用 oid 列存引用,并建索引。引用列是查询入口,加上索引避免全表扫描:

    sql
    CREATE INDEX idx_documents_content ON documents (content);
  3. 给大对象引用列加 NOT NULL 与外键式约束。虽然 oid 无法建真正的外键,但可以在应用层或触发器里保证引用非空且有效。

  4. 用 lo 扩展的 lo_manage 触发器处理常规增删改,减少手工 lo_unlink 遗漏:

    sql
    CREATE TRIGGER t_content BEFORE UPDATE OR DELETE ON documents
        FOR EACH ROW EXECUTE FUNCTION lo_manage(content);
  5. 把 vacuumlo -n 加入例行巡检,先 dry-run 观察,再确认后正式执行。频率建议与 VACUUM 巡检一致。

  6. 大对象的读写务必包在显式事务里,并使用 BEGIN ... COMMIT 而不是依赖自动提交:

    sql
    BEGIN;
    -- lo_open / loread / lowrite / lo_close
    COMMIT;
  7. 权限授予遵循最小权限原则,只给需要的角色 SELECT 或 UPDATE,不要图省事给 ALL。

  8. 严格控制 pg_read_server_files / pg_write_server_files 的授予,这两个角色等价于服务器文件系统的读写权限。

  9. 大文件上传下载走客户端接口(libpq / JDBC / PHP pgsql),不要用服务端 lo_import / lo_export,前者不依赖服务器文件系统,权限面更小。

  10. 监控 pg_largeobject 的体积与孤儿数量,把它纳入容量规划:

    sql
    SELECT pg_size_pretty(pg_total_relation_size('pg_largeobject'))
             AS lo_table_size,
           count(*) AS metadata_rows
    FROM pg_largeobject_metadata;
  11. 备份策略要显式确认大对象被包含。pg_dump 默认包含大对象,但指定 -n(只导某个 schema)时会排除;如果需要排除可用 -B,需要包含可用 -b。

  12. 避免把大对象用于需要逻辑复制或逻辑订阅的数据。这条是选型红线,一旦用了就没有官方推荐的变通方案。

参考链接 ​