外列(External Column)是PolarDB-X提供的将表中大字段(如AI会话正文、JSON快照、图片或PDF等二进制附件)从主表卸载到OSS的能力。建表时只需在大字段后加上EXTERNALIZE关键字,业务SQL写法零改动,即可获得更低存储成本、更稳服务表现以及更优内存利用率。
背景说明
Agent / Copilot类应用正在成为OLTP数据库中增长最快、最“重”的负载。以一张典型的chat_messages表为例,结构化元数据(session_id / user_id / role / ts / token)每行不过百来字节,承担几乎所有过滤和聚合。而content / tool_calls / context_snapshot / attachments等JSON大字段,单条从数KB到数MB不等,一次RAG检索回灌的上下文、一段工具返回的长JSON、一张截图的base64,都会让单行轻松突破百KB。这部分Payload占总数据量95%以上,会带来较大存储成本、Buffer Pool争抢污染、备份体积膨胀等代价。
PolarDB-X的外列是一种将表中的大字段(例如AI会话的对话正文、思考链,或者图片、PDF等二进制附件)从主表拆到OSS的能力。拆出去之后,主表只保留一个地址指针,大字段的实体存储在OSS上。在PolarDB-X使用外列无需改动业务,建表时在大字段后面加一个EXTERNALIZE关键字即可,INSERT / UPDATE / DELETE / SELECT写法与普通表完全一样。
功能优势
更低的成本:大字段存入OSS,存储成本从SSD降为对象存储价格,同时基于OSS的备份采用重删等技术,备份成本更低。
更稳的服务:数据节点(DN)的Binlog体积从MB级降到KB级,同库其他业务的Commit不再受大列写入影响,尾部延迟更稳定。
更优的内存:原本Buffer Pool会因大文本挤占有限的内存空间,基于外列卸载,Buffer Pool只缓存高频访问的小元数据,相同内存下有更好的缓存效率。
典型场景
AI Agent的会话存储是外列的典型场景。一次会话里塞着多轮对话、代码片段、工具调用结果,单条消息从KB到MB都有,会话总量从百万膨胀到亿级。直接存InnoDB会让Binlog膨胀、Buffer Pool被挤占,且业务写MySQL + OSS双系统又要考虑事务一致性和垃圾回收(GC)。外列把两者结合:InnoDB的简单和事务语义 + OSS的低成本,复杂度由引擎承担。
CREATE TABLE chat_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id BIGINT,
user_id BIGINT,
role ENUM('user','assistant','system'),
token_count INT,
created_at DATETIME,
content LONGTEXT EXTERNALIZE,
KEY idx_session (session_id, created_at)
) PARTITION BY KEY(session_id);
-- 写入
INSERT INTO chat_messages(session_id, user_id, role, content, token_count, created_at)
VALUES (10001, 42, 'user', '{...对话内容...}', 1200, NOW());
-- 拼接上下文
SELECT role, content FROM chat_messages
WHERE session_id = 10001 ORDER BY created_at DESC LIMIT 20;
-- 更新
UPDATE chat_messages SET content = '{...新内容...}' WHERE id = 1;
-- 删除
DELETE FROM chat_messages WHERE session_id = 10001;对 ORM(MyBatis/JPA)及 AI 框架(LangChain/LlamaIndex)完全透明,业务侧零代码改造,仅凭后端单张MySQL表即可实现会话数据的无感接入。
版本要求
仅支持企业版实例。
实例版本需为2.6.0_5.4.21-20260619及以上版本。
语法
建表
在列定义末尾加上EXTERNALIZE关键字,表示这一列的数据要外置到OSS:
CREATE TABLE chat_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id BIGINT,
user_id BIGINT,
role ENUM('user','assistant','system'),
token_count INT,
created_at DATETIME,
content LONGTEXT EXTERNALIZE,
KEY idx_session (session_id, created_at)
) PARTITION BY KEY(session_id);加列
已有的表也可以通过ALTER TABLE加外列:
ALTER TABLE chat_messages ADD COLUMN reasoning LONGTEXT EXTERNALIZE;支持的类型
文本族:
TEXT / TINYTEXT / MEDIUMTEXT / LONGTEXT二进制族:
BLOB / TINYBLOB / MEDIUMBLOB / LONGBLOB
字符集要求列字符集省略或属于utf8家族(utf8 / utf8mb3 / utf8mb4),其他字符集会在建表时直接报错。
查看建表语句
SHOW CREATE TABLE输出的是逻辑视图:您看到的仍然是content LONGTEXT EXTERNALIZE,而不是底层物理列名。对您来说,物理实现是完全透明的。
业务透明
建表之后,业务SQL的写法没有任何变化:
INSERT INTO chat_messages(session_id, user_id, role, content, token_count, created_at)
VALUES (10001, 42, 'assistant', '{...回复内容...}', 800, NOW());
UPDATE chat_messages SET content = '{...新内容...}' WHERE id = 1;
SELECT content FROM chat_messages WHERE session_id = 10001;数据库在底层自动完成大字段到OSS的上传和拉取,应用层感知不到这个过程。如果一条SQL不涉及外列(比如只查ROLE),它走的是纯数据节点(DN)路径,性能与普通表完全无差异。
工作原理

引擎按列大小自动分流
百KB以上:写入时直接上传到OSS,主表只存一个地址指针。Commit前等OSS确认完成,事务语义完整。
KB级:先写入本地staging表(InnoDB),落表即返回,后台异步flush到OSS。写入延迟 = 直写InnoDB,长期存储仍是OSS价格。
读取走四级缓存
本地内存 → 本地SSD → RPC到其他计算节点(CN) → OSS回源。绝大多数热读在前两级命中,并不会流转至OSS。
删除由引擎自动管理
DELETE一行后,对应的OSS对象在所有活跃事务结束后被GC清理,应用层无需写对账脚本或手动删除。
核心收益
写入不变慢:KB级大列的写入延迟和直接写InnoDB一样,百KB以上大列在高并发下比InnoDB更快。
读取几乎无感:热读命中本地缓存,延迟与普通列查询相当。
运维零负担:无需对账脚本、无需手动清理OSS,引擎全自动。
使用示例
场景一:多模态消息的列拆分
一个AI assistant的回复可能包含:回答正文、附件、工具调用结果、思考链。这些字段读频率不同:content每次拼上下文都要读,reasoning通常只在审计时才被访问。
CREATE TABLE chat_messages_v2 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id BIGINT,
role ENUM('user','assistant','system'),
token_count INT,
created_at DATETIME,
content LONGTEXT EXTERNALIZE,
attachments LONGBLOB EXTERNALIZE,
tool_calls LONGTEXT EXTERNALIZE,
reasoning LONGTEXT EXTERNALIZE,
KEY idx_session (session_id, created_at)
) PARTITION BY KEY(session_id);
-- 写入一条多模态消息
INSERT INTO chat_messages_v2(session_id, role, content, reasoning, token_count, created_at)
VALUES (10001, 'assistant', '回答正文...', '思考链内容...', 3500, NOW());
-- 日常拼接只读 content
SELECT content FROM chat_messages_v2 WHERE session_id = 10001 ORDER BY created_at;
-- 审计时才读 reasoning
SELECT reasoning FROM chat_messages_v2 WHERE id = 12345;缓存粒度按列走:每个外列对应独立的OSS对象和缓存条目。
content被反复SELECT,LRU一直把它留在本地缓存。reasoning没人碰,自然被淡出到OSS。审计时再
SELECT reasoning,回源OSS几十毫秒拉回来,调试场景完全可接受。
场景二:和AI框架对接
外列对 ORM(MyBatis/JPA)及 AI 框架(LangChain/LlamaIndex)完全透明,以Spring AI和LangChain4j为例:两种契约、两种schema,都是纯SQL。
Spring AI(按条追加)
“按条追加”语义跟前面的chat_messages表天然匹配,直接复用同一张表:
-- 复用前面的 chat_messages 表
CREATE TABLE chat_messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
session_id BIGINT,
user_id BIGINT,
role ENUM('user','assistant','system'),
token_count INT,
created_at DATETIME,
content LONGTEXT EXTERNALIZE,
KEY idx_session (session_id, created_at)
) PARTITION BY KEY(session_id);public class PolarDBXChatMemory implements ChatMemory {
private final JdbcTemplate jdbc;
@Override
public void add(String conversationId, Message message) {
jdbc.update(
"INSERT INTO chat_messages(session_id, role, content, created_at) "
+ "VALUES (?, ?, ?, NOW())",
conversationId, message.getMessageType().name(), message.getText());
}
@Override
public List<Message> get(String conversationId, int lastN) {
return jdbc.query(
"SELECT role, content FROM chat_messages WHERE session_id = ? "
+ "ORDER BY created_at DESC LIMIT ?",
(rs, i) -> toMessage(rs.getString("role"), rs.getString("content")),
conversationId, lastN);
}
@Override
public void clear(String conversationId) {
jdbc.update("DELETE FROM chat_messages WHERE session_id = ?", conversationId);
}
}LangChain4j(全量替换)
“全量替换”语义更适合一张KV表,整个session的消息序列化成JSON存进一个外列:
CREATE TABLE chat_memory_store (
session_id BIGINT PRIMARY KEY,
messages LONGTEXT EXTERNALIZE
);public class PolarDBXChatMemoryStore implements ChatMemoryStore {
private final JdbcTemplate jdbc;
@Override
public List<ChatMessage> getMessages(Object sessionId) {
String json = jdbc.query(
"SELECT messages FROM chat_memory_store WHERE session_id = ?",
rs -> rs.next() ? rs.getString(1) : null, sessionId);
return json == null ? List.of() : ChatMessageDeserializer.messagesFromJson(json);
}
@Override
public void updateMessages(Object sessionId, List<ChatMessage> messages) {
String json = ChatMessageSerializer.messagesToJson(messages);
jdbc.update(
"INSERT INTO chat_memory_store(session_id, messages) VALUES (?, ?) "
+ "ON DUPLICATE KEY UPDATE messages = VALUES(messages)",
sessionId, json);
}
@Override
public void deleteMessages(Object sessionId) {
jdbc.update("DELETE FROM chat_memory_store WHERE session_id = ?", sessionId);
}
}使用限制
DDL
操作 | 是否支持 | 说明 |
| 支持 | 表必须有主键。 |
| 支持 | 表必须有主键。 |
| 支持 | OSS对象异步清理。 |
| 不支持 | 需先 |
| 不支持 | 需先 |
| 不支持 | 外列不能设默认值。 |
普通索引( | 不支持 | 但可以出现在全局二级索引(GSI)的覆盖(covering)列里。 |
删除最后一个列存索引(CCI) | 不支持 | 外列依赖列存索引(CCI)工作。 |
| 不支持 | — |
| 不支持 | — |
| 不支持 | — |
| 不支持 | — |
非utf8字符集 | 不支持 | 仅允许 |
DQL / DML
当WHERE条件或UPDATE SET涉及外列时,SQL不会下推到数据节点(DN)执行,而是在计算节点(CN)侧完成,执行代价和耗时可能会增加。实际使用中,大字段通常按主键定位,这个限制一般不会成为瓶颈。
性能参考
以下数据来自4个计算节点(CN) + 4个数据节点(DN)的公有云实例,简单Schema(主键 + 一个MEDIUMBLOB)。
小列写入(KB级)
小列走staging路径,写入就是一次数据节点(DN)本地的InnoDB INSERT。只要没有触发反压(staging表积压达到阈值),写入延迟和直接写InnoDB完全一致,对KB级大列的场景非常友好。
大列写入(百KB以上)
大字段直写OSS方案的写入性能受数据大小与并发度的双重驱动。测试表明,外列直写与传统 InnoDB之间存在一个明确的性能拐点(Crossover Point):
性能拐点大幅提前:在单线程下,数据量达500 KB 时外列方案开始超越InnoDB;而在64并发下,该拐点提前至300 KB;至256并发时,拐点进一步降至140 KB。并发度越高,外列获得优势的起点越低。
突破I/O天花板:跨越拐点后,两者的差距呈指数级拉大。在大字段与高并发的双重压力下,InnoDB会迅速触及磁盘I/O瓶颈并陷入剧烈的锁竞争,导致吞吐量不增反降。而外列方案的主表仅写入数十字节的地址指针,绕过了这一物理限制。
场景对比:在“2 MB 数据 / 256 并发”的高负载场景下,InnoDB的平均延迟已飙升至5.5秒,P99 延迟突破16秒,实际已丧失生产可用性。而外列方案仍能稳定输出478 QPS的吞吐量,平均延迟控制在514毫秒,表现出极强的健壮性。
热读
热读路径上外列和InnoDB各有优劣,整体大致持平。实测16并发下,100 KB列上InnoDB更快,500 KB起外列更快。
但热读上外列有一个隐性优势:不挤占数据节点(DN)的Buffer Pool。InnoDB读大列时整个LOB页会塞进Buffer Pool,这些“读一次就不再热”的大页把索引页和热数据挤出去,同库TP业务跟着变慢。外列从计算节点(CN)本地缓存读取,不经过数据节点(DN)的Buffer Pool,索引页常驻不受影响。
代表性数据点
场景 | 列大小 | 并发 | InnoDB QPS / Avg / P99 | 外列 QPS / Avg / P99 |
INSERT | 100 KB | 64 | 3994 / 16 ms / 37 ms | 1961 / 33 ms / 64 ms |
INSERT | 500 KB | 64 | 818 / 78 ms / 225 ms | 1258 / 51 ms / 87 ms |
INSERT | 1 MB | 64 | 317 / 201 ms / 1195 ms | 793 / 81 ms / 162 ms |
INSERT | 2 MB | 256 | 40 / 5561 ms / 48687 ms | 478 / 514 ms / 1549 ms |
UPDATE | 1 MB | 64 | 247 / 257 ms / 1507 ms | 526 / 121 ms / 267 ms |
Hot Read | 100 KB | 16 | 8120 / 2.0 ms / 8.2 ms | 5279 / 3.0 ms / 5.9 ms |
Hot Read | 500 KB | 16 | 3688 / 4.3 ms / 11.7 ms | 4186 / 3.8 ms / 19.9 ms |
Cold Read | 100 KB | 64 | — | 1538 / 28 ms / 48 ms |
Cold Read | 1 MB | 64 | — | 250 / 95 ms / 190 ms |
冷读(本地缓存MISS后从OSS回源)主要是一个OSS GET的固定开销 + 数据传输时间,100 KB约28 ms、1 MB约95 ms。业务对冷读延迟敏感的场景可考虑提前预热。
场景选型
适合外列的场景:按主键 / 会话ID写入和读取的大字段(消息正文、JSON快照、思考链)。也适合存储文件等二进制数据(图片、PDF、音视频)—原本需要业务自己写OSS + 元数据双系统的场景,现在可以直接存进外列,事务一致性和生命周期管理由引擎统一承担。访问模式是“整段存、整段取”,外列正好匹配。
不太合适的场景:
大量冷读回查:缓存MISS后回源OSS有20+ ms延迟。如果场景是频繁翻查历史冷数据且对延迟敏感,需要权衡。
需要对大字段做检索:外列不支持二级索引、全文检索、LIKE模糊查询。需要被检索的字段应留在主表普通列上。