达梦内存管理:内存池、缓冲区与排序区

核心论点:达梦(DM8)并不把每一次小内存申请都交给操作系统的 malloc/free,而是用自管内存把「数据页缓存、共享内存池、会话运行时内存」拆开管理。BUFFER、SORT_BUF_SIZE、HJ_BUF_SIZE 设小了会把复杂查询赶出内存、打到临时表空间;设大了会在多会话下把物理内存乘爆,甚至拖垮操作系统。本文按「为什么自管 → 两类内存池差在哪 → 参数过大过小会怎样 → 排序区/哈希区在复杂 SQL 里怎么工作 → 完整上机」展开,SQL 均可在单实例上复现。


前言

本文所要解决的问题有:
1)共享内存池和运行时内存池,谁在实例启动时向操作系统要内存,谁在会话起来之后才出现?
2)BUFFERSORT_BUF_SIZE 设得太小和太大,分别会在哪一层出问题?
3)一条带 ORDER BY 和等值 JOIN 的 SQL,扫描、排序、哈希分别消耗哪一块内存?


1. 为什么达梦要自己管内存,而不是每次调用操作系统

数据库是典型的「高频申请、高频释放」软件:解析 SQL、建执行计划、排序、哈希连接、缓存数据页,每秒钟可能发生成千上万次小块内存进出。如果每一次都走操作系统:

  • 要陷入内核,发生系统调用和可能的线程切换;
  • 堆上容易产生碎片,长时间运行后「总量够、却申请不到连续块」;
  • 数据库自己看不到「这块内存是哪个会话、哪条 SQL 拿走的」,泄露和越界很难定位。

达梦官方文档《DM 内存结构》中:

「数据库管理系统是一种对内存申请和释放操作频率很高的软件,如果每次对内存的使用都使用操作系统函数来申请和释放,效率会比较低,加入自己的内存管理是 DBMS 系统所必须的。」

——出处:DM 内存结构(达梦产品手册)

因此 DMServer 作为单进程、多线程的共享服务器,启动时先向操作系统要几块「大池子」,之后绝大多数小分配都在池内完成:用完归还池,而不是归还操作系统。操作系统看到的,主要是 dmserver 进程的 RSS;池内部谁占用、有没有溢出,要靠 V$MEM_POOLV$BUFFERPOOL 来看。

关系结构:

操作系统物理内存
 └── dmserver 进程
      ├── 数据缓冲区 BUFFER / KEEP / RECYCLE / FAST
      │     (数据页;自由链 / LRU 链 / 脏链)
      ├── 共享内存池  MEMORY_POOL  (实例启动时向 OS 申请)
      │     └── 排序块、哈希块等短生命周期小片内存
      └── 运行时内存池
            ├── SESSION 池   (会话建立时创建,断开时释放)
            └── VM 池        (语句执行期)
区域 典型参数 存什么 生命周期
数据缓冲区 BUFFER / KEEP / RECYCLE / FAST_POOL_PAGES 数据页、索引页 实例级,启动时格式化成分页
共享内存池 MEMORY_POOL / MEMORY_TARGET / MEMORY_EXTENT_SIZE 字典、SQL 计划缓存、日志缓冲、排序/哈希等短生命周期块 实例启动时向 OS 申请,可扩展/收缩
运行时内存池 会话池、虚拟机(VM)池 本会话的解析、执行器私有结构 会话建立时创建,会话结束时销毁

BUFFER 不是内存池。它是按数据页切好的高速缓存,用自由链 / LRU 链 / 脏链管理。内存池解决的是「小块分配不要每次找 OS」。把这两件事当成同一个旋钮,是调参里最常见的误区。


2. 共享内存池 与 运行时内存池

2.1 共享内存池

共享内存池由 MEMORY_POOL 指定初始大小。实例启动时向操作系统申请这一大片。之后:

  • 需要小片内存:从池里切;
  • 用完:还回池,供其他模块复用;
  • 池不够:按 MEMORY_EXTENT_SIZE 扩展;
  • MEMORY_TARGET 是扩展后的收缩水位:超过该值后,空闲时尽量缩回目标大小。

高并发时把 MEMORY_POOL 适当调大,是为了减少运行期再向操作系统伸手。新人培训课件《DM7 性能诊断与优化》里的建议逻辑是:公共内存池过小,会频繁向 OS 申请,拉低效率。

相关参数还可以看到 MEMORY_N_POOLS / N_MEM_POOLS:把公共池切成多片,降低并发分配时的锁冲突。并发会话多时,片数过少会出现「内存总量还够,但分配排队」的假象。

2.2 运行时内存池

除共享池外,功能模块还会持有自己的运行时池。对应用最有体感的是:

  • 会话内存池(SESSION):客户端连上来、服务器为该会话准备私有环境时创建。事务控制块、非特殊操作符的临时结构,多从这里出。
  • 虚拟机内存池(VIRTUAL MACHINE / VM):语句真正执行时创建。执行器、表达式计算等大量工作内存来自这里。

它们的共同特点是:随使用产生,随会话(或语句)结束释放。这些运行时池会从操作系统申请一片,作为本模块自己的池来用。
还可以这样理解分工:

  • 生命周期与整个会话绑定的(会话池、VM 池)→ 独立池,会话之间互不抢同一把分配锁,这正是连接池「会话要保持」的底层原因之一;
  • 生命周期只覆盖某个操作符的(排序块、哈希块)→ 往往不再单独向 OS 再开一个池,而是从共享池里切,用完立刻还。

所以「运行时内存池 = 会话动态申请」这句话是对的,但要补半句:动态申请的对象,优先是达梦自己的池,而不是每次直接 malloc 只有池的初始值用尽、尚未顶到 target 时,才会再向操作系统要;超过 target 之后,更倾向于回到共享池,避免和 OS 频繁交互。

2.3 一条 SQL 实际会碰几块池

简化时间线:

客户端连接
    └─ 创建 SESSION 运行时池          ← 会话级,连接在就在
收到 SQL
    ├─ 解析 / 绑定 / 计划
    │     └─ 字典缓冲、SQL 缓冲(共享池侧)
    └─ 执行
          ├─ 创建 / 使用 VM 池         ← 本语句执行期
          ├─ 扫表:数据页走 BUFFER
          ├─ ORDER BY / DISTINCT:排序区(短生命周期,多从共享池切)
          └─ HASH JOIN / HASH GROUP:哈希区(同上,超限则外存哈希)
会话断开
    └─ SESSION / VM 池归还

V$MEM_POOL 时,会同时看到名字像共享池的条目,以及大量带会话线程号的运行时池。把 CREATORV$SESSIONS.THRD_ID 关联,就能回答「哪条会话把内存抬起来了」。


3. BUFFER、SORT_BUF_SIZE、哈希参数:太小和太大分别怎样

过小、过大的对照见本节各小节和第 3.4 表。是否真的外溢、是否真的在淘汰,只能看第 5 节里 V$BUFFERPOOLRECYCLESET TIMING ON 的上机截图。

3.1 BUFFER:数据页高速缓存

启动时按 BUFFER 向 OS 申请连续内存,按页大小切开,挂到自由链。读数据:先找缓冲,没有再读盘并放入 LRU;改数据:页变脏,进脏链,由检查点/淘汰刷盘。

设太小:

  • 自由链很快耗尽,LRU 不停淘汰;
  • V$BUFFERPOOLFREE 接近 0,N_DISCARD64(或淘汰计数)持续上涨;
  • 缓冲命中率低,iostat%util / await 升高,SQL 看起来「优化器没问题,但就是慢」。

设太大:

  • 实例启动直接失败,或启动后把 OS 可用内存吃光;
  • Linux 开始 Swap(si/so 不为 0),此时再快的缓冲也比不过被换到磁盘上的「假内存」;
  • 挤压共享池、排序区、哈希区,复杂查询反而更爱打临时表;
  • 脏页总量变大,检查点刷盘更猛,出现周期性 IO 尖峰。

经验范围(不是公式):单机 OLTP 常把 BUFFER 放到物理内存的 60%~80%,且 MAX_BUFFER 建议与 BUFFER 对齐,避免无节制动态扩张。数据量比内存小,可以按数据量来,没必要为了「看起来大方」把缓冲开到远超数据文件。

BUFFER_POOLS 是缓冲区分片数,并发高时用质数(如 47、101)降低缓冲内部闩锁冲突。它不增加总容量,只改变并发结构。

RECYCLE 是临时表空间用的缓冲。排序外溢、哈希外溢、WITH 物化、临时表,都会打到这里。只调 SORT_BUF_SIZE 却把 RECYCLE 留在默认几十 MB,外排序依然会很痛。

3.2 SORT_BUF_SIZE:单线程排序上限

排序区不是启动时划死的一块「排序专用大数组」,而是每次排序先申请、排完就释放SORT_BUF_SIZE 约束的是**单个排序操作(常见口径:单个线程)**能用的内存上限。

还有一组配套参数(名称以你版本 V$PARAMETER 为准):

参数 作用
SORT_BUF_SIZE 单次/单线程排序内存上限
SORT_BUF_GLOBAL_SIZE 实例内排序内存总和上限
SORT_BLK_SIZE 排序分片大小
SORT_FLAG 0 整片排序;1 大内存分片排序(一般不建议长期打开)

设太小:

  • 内排序变外排序:排不下的批次写入临时段,再多路归并;
  • RECYCLE 和临时表空间上涨,磁盘排序比内存排序慢一个数量级很常见;
  • 建索引、ORDER BY 大结果集、GROUP BY/DISTINCT 都会受影响。

设太大:

  • 它往往是会话级可改参数。100 个会话同时排序,理论峰值接近 100 × SORT_BUF_SIZE,再和 SORT_BUF_GLOBAL_SIZE 去争;
  • 共享池被排序块占满,其他会话解析、哈希一起挨饿;
  • 连接池场景下,「测环境单会话很快、生产一高峰就 OOM」多半是这个乘法没算。

手册对默认值的建议随版本有 2MB / 20MB 等差异,以当前实例为准,不要背死数字。建索引可以会话内临时调大,作业跑完改回去。

3.3 哈希区:HJ_BUF_* 与 HAGR_BUF_*

哈希连接消耗内存,因为要选较小的一侧做 Build,在内存里建哈希表,再探测另一侧。达梦把哈希缓冲称作虚拟缓冲:并不是预先划死一块「哈希专用物理内存」,而是按数据量估算;能放下就在内存池里做哈希,放不下就走外存哈希(分区写临时段,再逐分区哈希)。

官方手册原文:

「DM8 提供了为哈希连接而设定的缓冲区,不过该缓冲区是个虚拟缓冲区。……如果计算出的数据量大小超过了哈希缓冲区的大小,则使用 DM8 创新的外存哈希方式;如果没有超过哈希缓冲区的大小,实际上还是使用内存池来进行哈希操作。」

——出处:同上,《DM 内存结构》3.4 节

参数 含义
HJ_BUF_SIZE 单次哈希连接可用内存
HJ_BUF_GLOBAL_SIZE 实例内哈希连接内存总和
HJ_BLK_SIZE 哈希操作符每次分配块大小
HAGR_BUF_SIZE / HAGR_BUF_GLOBAL_SIZE 哈希分组、DISTINCT、集合运算、分析函数等
HAGR_HASH_SIZE 聚集时哈希桶个数

HJ_BUF_SIZE 太小: Build 表估不准或真实更大,反复外存哈希,等值连接没有索引时尤其明显。
HJ_BUF_SIZE 太大: 单条 SQL 吃得很爽,多条并发 HASH JOIN 会顶满 HJ_BUF_GLOBAL_SIZE,再和 BUFFER 抢物理内存。
HJ_BUF_GLOBAL_SIZE 太大: 看起来「哈希很宽裕」,实际是从整机内存里挖走一块,BUFFER 命中率下降,OLTP 与分析混跑时最容易中招。

嵌套循环靠索引反复探测,几乎不吃哈希区;哈希连接吃内存、换的是「没索引也能做大结果等值连接」。调哈希参数之前,先看执行计划里到底是 NEST LOOP 还是 HASH JOIN,否则会调错对象。

3.4 一张表收口

参数 过小 过大
MEMORY_POOL 频繁向 OS 扩展;N_EXTEND_EXCLUSIVE 长期 > 0 启动占用高,空闲也浪费;可能挤 BUFFER
BUFFER 命中率低、淘汰多、物理 IO 高 Swap、启动失败、检查点 IO 风暴、挤占排序/哈希
SORT_BUF_SIZE 外排序、临时表空间涨、ORDER BY/建索引慢 会话数一乘就爆;挤共享池
HJ_BUF_SIZE 外存哈希、等值大连接变慢 单 SQL 吃内存;并发哈希互相排队
RECYCLE 外排序/外哈希即使「算法对了」也被磁盘拖死 临时场景不占那么多时浪费

4. 复杂查询里,排序区和哈希区到底在干什么

ORDER BY / GROUP BY / DISTINCT
        │
        ├─ 工作集 ≤ SORT_BUF_SIZE  →  内存内排序  →  得到有序结果
        └─ 工作集 >  SORT_BUF_SIZE  →  写入临时段(RECYCLE)→ 多路归并

等值 HASH JOIN
        │
        ├─ Build 侧 ≤ HJ_BUF_SIZE  →  内存中建哈希表并探测
        └─ Build 侧 >  HJ_BUF_SIZE  →  分区写入临时段 → 逐分区再哈希

上面只是机制;你的库走的是哪一条,以实验 5、实验 6 的 EXPLAIN 和耗时截图为准。

4.1 排序区:ORDER BY 只是入口

会用到排序的,远不止 ORDER BY

  • ORDER BY / GROUP BY(无合适索引时)
  • DISTINCTUNION(去重)
  • MERGE JOIN 前的有序化
  • 创建索引时的键排序
  • 部分窗口函数

内排序: 数据量 ≤ SORT_BUF_SIZE(再受全局上限约束),全程内存完成,结束即释放。
外排序: 装不下就分批写临时段(走临时表空间,缓冲落在 RECYCLE),再归并。结果仍然正确,代价是 IO 和 CPU 双涨。

所以看到「这条 SQL 的计划并不差,但一加 ORDER BY 就慢」,优先看:

  1. 能不能用索引避免排序;
  2. 避免不了时,单次排序量是否远大于 SORT_BUF_SIZE
  3. 临时表空间和 RECYCLE 是否已经成为第二瓶颈。

4.2 哈希区:等值连接的「用内存换随机 IO」

哈希连接典型条件:

  • 等值连接(t1.c1 = t2.c1);非等值(>BETWEEN)通常不会选它;
  • 连接列缺索引,或优化器认为建哈希表比嵌套循环反复探索引更划算;
  • 选较小的 row source 做 Build 表。

内存足够:Build 侧在内存建哈希表,Probe 侧逐行探测,CPU 密集、IO 少。
内存不够:两表按哈希函数分区写入临时存储,再对每个分区做内存哈希——这就是外存哈希。分区次数越多,越接近「自己实现了一遍磁盘 hash join」。

HAGR_* 覆盖的是另一类哈希:GROUP BY 哈希聚合、DISTINCT、集合运算等。一条 SQL 完全可能同时占用排序区和哈希区,例如:

SELECT dept_id, COUNT(*), MAX(amount)
FROM  big_fact f
JOIN  dim_dept d ON f.dept_id = d.dept_id   -- 可能 HASH JOIN → HJ_BUF_*
GROUP BY dept_id                            -- 可能 HASH GROUP → HAGR_BUF_*
ORDER BY 2 DESC;                            -- 排序区 → SORT_BUF_*

扫描这些表的数据页仍然走 BUFFER。因此「复杂查询慢」要拆成三问:页缓命中够不够、哈希是否外溢、排序是否外溢。只加 BUFFER,解决不了后两问。

4.3 和达梦其他产品的关系(不只单实例)

  • 数据守护(DataWatch):备库同样有自己的 BUFFER 与内存池。备库内存往往小于主库,若按主库原样拷贝 dm.ini,备库可能起不来,或在重演 REDO 时把 OS 打满。
  • 读写分离 / DSC:每个节点一份缓冲,不存在「集群共享一块 BUFFER」。SQL 打到哪一节点,排序/哈希就在那一节点的运行时内存里发生。
  • DMETL / 数据迁移:大批量装数、排序清洗时,作业进程和数据库会话会叠加内存。ETL 主机和数据库主机的 BUFFER 规划要分开算,不要默认「都在一台机器上,BUFFER 开到 80%」。

5. 上机实验


第 1 步:看清四个参数(证明 BUFFER 和内存池不是一回事)

SELECT PARA_NAME, PARA_VALUE
FROM V$DM_INI
WHERE PARA_NAME IN (
    'BUFFER',
    'MEMORY_POOL',
    'SORT_BUF_SIZE',
    'HJ_BUF_SIZE'
);

上机结果图


第 2 步:再连一次库,证明运行时池跟着会话走

在窗口执行下列sql语句

SELECT SESS_ID, USER_NAME, STATE, THRD_ID
FROM V$SESSIONS
ORDER BY SESS_ID;

新连接的窗口里再执行上面那句。用户多出一行 SESS_ID

比内存池:

SELECT COUNT(*) AS POOL_CNT FROM V$MEM_POOL;

SELECT NAME, COUNT(*) AS CNT
FROM V$MEM_POOL
GROUP BY NAME
ORDER BY CNT DESC;

第一次
第二次

第 3 步:造表 + 对比排序内存

CREATE TABLE MEM_FACT (
    ID     INT NOT NULL,
    DEPT   INT NOT NULL,
    AMOUNT DECIMAL(18,2),
    PAD    VARCHAR(180)
);

INSERT INTO MEM_FACT
SELECT LEVEL,
       MOD(LEVEL, 50) + 1,
       MOD(LEVEL, 1000) * 0.37,
       RPAD('X', 180, 'Y')
FROM DUAL
CONNECT BY LEVEL <= 50000;
COMMIT;

SELECT COUNT(*) FROM MEM_FACT;

让服务器把行按 ID 排完再写入另一张表,避免 Manager 把行画在屏幕上。

先热身一次(耗时丢掉,只为把数据打进 BUFFER):

SELECT COUNT(*), SUM(ID) FROM MEM_FACT;

再准备结果表,同一窗口继续:

CREATE TABLE MEM_OUT (ID INT, PAD VARCHAR(180));

SP_SET_PARA_VALUE(1, 'SORT_BUF_SIZE', 1);
TRUNCATE TABLE MEM_OUT;
INSERT INTO MEM_OUT SELECT ID, PAD FROM MEM_FACT ORDER BY ID DESC;
COMMIT;

看这条 INSERT 的耗时。同一窗口再:

SP_SET_PARA_VALUE(1, 'SORT_BUF_SIZE', 32);
TRUNCATE TABLE MEM_OUT;
INSERT INTO MEM_OUT SELECT ID, PAD FROM MEM_FACT ORDER BY ID DESC;
COMMIT;

每档连跑两遍,只记第二遍(第一遍可能还在填缓冲)。比较的是 INSERT ... ORDER BY 的耗时。

最后把排序缓冲改回第 1 步的原值:

SP_SET_PARA_VALUE(1, 'SORT_BUF_SIZE', 2);

SORT_BUF_SIZE=1
SORT_BUF_SIZE=32

第 4 步:对比哈希内存(大表自己连自己)

维表很小的 JOIN 往往看不出差异,所以用自连接把 Build 侧做大。

SP_SET_PARA_VALUE(1, 'HJ_BUF_SIZE', 2);

SELECT /*+ USE_HASH(A, B) */ COUNT(*)
FROM MEM_FACT A, MEM_FACT B
WHERE A.ID = B.ID
  AND A.DEPT = 1;

看耗时。同一窗口再:

SP_SET_PARA_VALUE(1, 'HJ_BUF_SIZE', 64);

SELECT /*+ USE_HASH(A, B) */ COUNT(*)
FROM MEM_FACT A, MEM_FACT B
WHERE A.ID = B.ID
  AND A.DEPT = 1;

/*+ USE_HASH(A, B) */ 是提示优化器走哈希连接。两次差不多,说明本机数据量未打满 HJ_BUF,差异不明显。

做完清理:

DROP TABLE MEM_FACT;

小
大

6. 调参时的一组可执行原则

  1. 先分清三种慢: 页缓命中不够(BUFFER)、排序外溢(SORT_* + RECYCLE)、哈希外溢(HJ_* / HAGR_*)。用 EXPLAIN + V$BUFFERPOOL + V$MEM_POOL 三件套,不要凭感觉加内存。
  2. BUFFER 按数据热度和物理内存一起算,不是按「参数表里谁默认大」谁就该最大。改完必须重启,用淘汰计数而不是「感觉」验收。
  3. SORT_BUF_SIZE 按单条 SQL 的排序工作集估,再乘并发。 连接池里的每个活连接都可能同时排序。
  4. 哈希参数看 Build 侧大小。 小维表哈希吃不满 HJ_BUF_SIZE;大表无索引等值连接才需要认真调,并给 HJ_BUF_GLOBAL_SIZE 留并发余量。
  5. 共享池负责「少找 OS」N_EXTEND_EXCLUSIVE 长期大于 0,再考虑加 MEMORY_POOL / MEMORY_TARGET,而不是先把 BUFFER 再加 20GB。
  6. 守护、集群、ETL 节点各自算一份内存账。 达梦产品线里每个进程都有自己的缓冲与池,拷贝一份主库 dm.ini 到小规格备库,是现场常见事故。

7.总结

达梦把内存管起来,不是为了多几个参数名字,而是为了在单进程多线程模型下,把「数据页缓存」和「小块工作内存」从操作系统的通用堆里拆出来。共享内存池在启动时要一大块,换的是后续少做系统调用;运行时内存池跟着会话走,换的是会话之间少抢同一把分配锁,也换来连接池必须被正确关闭、否则池不释放。排序区和哈希区则是复杂查询里最容易被忽略的两条旁路:它们在够用时几乎无感,不够用时不会立刻报错,而是改走临时表空间——于是故障表现常常是「磁盘很忙、SQL 计划却看不出明显错误」。


参考(引用已在正文用双引号标出)

  1. 达梦产品手册:《DM 内存结构》,https://eco.dameng.com/document/dm/zh-cn/pm/memory-structure.html
  2. 达梦培训材料:《DM7 性能诊断与优化》(实例内存参数表:MEMORY_POOLBUFFERSORT_BUF_SIZEHJ_BUF_*
  3. 动态视图:V$PARAMETER / V$DM_INIV$MEM_POOLV$BUFFERPOOLV$SESSIONSV$SQL_STAT(以所装版本系统视图说明为准)
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐