金仓数据库系统性能调优实战复盘:内存、IO、锁与阻塞的排查之路

金仓数据库系统性能调优实战复盘:内存、I/O、锁与阻塞的排查之路

说起来有点不好意思,接手这套金仓数据库的时候,心里其实没底。项目是某省数字政务平台,跑了大半年,数据量翻了几番,系统开始出现各种"老年病"——页面转圈、导出超时、早高峰CPU飙到95%以上、偶尔还来个死锁把整个批处理卡死。领导给的期限是一个月内"稳住"。那段时间我每天的常态就是在服务器和数据库之间来回切,查参数、看等待、调配置、测效果,循环往复。

后来系统总算稳住了。过程谈不上多高大上,就是一步步排查、一个个参数试过来的。这篇复盘着重写系统级的调优——内存怎么配、I/O瓶颈怎么解、锁和阻塞怎么查。SQL优化那部分另开一篇,这里只聊系统层面的事。

一、先弄清楚系统长什么样

接手第一天我没急着动任何参数。先搭监控,跑数据。

环境信息:两台KingbaseES V9服务器组成主备架构。一台物理机,128核CPU、256GB内存、全闪存储。数据库版本是V9R1,每天处理约3000万笔交易,高峰期并发连接数在600-800之间。

装好 sys_kwr 插件,配置好快照间隔,先跑24小时看基线。

shared_preload_libraries = 'sys_kwr, sys_stat_statements'sys_kwr.enable = onsys_kwr.interval = 30sys_kwr.history_days = 14sys_kwr.topn = 20sys_kwr.language = 'chinese'track_io_timing = ontrack_wait_timing = onsys_stat_statements.track = 'top'

重启后,等了一天半,手动跑了一份KWR报告看摘要。结果让我吃了一惊。

负载性能表显示,DB Time(数据库时间)总计372分钟,而采样的wall-clock时间只有60分钟。意味着平均有6个CPU核心同时在忙。这是128核的机器,理论上远没到瓶颈,但看Top 10前台等待事件,排第一的 DataFileRead 占了DB Time的43%,排第二的 wal_insert 占了12%。说白了就是——内存太小,数据频繁读盘;同时日志写入也有排队压力。

这就是系统的核心症结: 256GB内存,给数据库的共享缓存只有2GB。默认配置上生产,典型悲剧。

二、第一个坑:内存配错了,IO来扛

KWR报告的"实例效率百分比"里有一个指标让我印象特别深: Buffer Hit%(缓冲区命中率),只有85%。这意味着用户请求的数据有15%不在缓存里,需要从磁盘读。全闪存储在扛着,延迟还能忍受,但这个命中率在生产系统上绝对不能接受。

当时 shared_buffers 设的是2GB(出厂默认值0.25倍物理内存算出来60多GB,但实际配置被接手前的实施人员调低了)。对于一个256GB的机器来说,这个值浪费了绝大多数物理内存。

这涉及一个常见的误解。很多刚接触这类数据库的人怕把 shared_buffers 设大了会OOM,不敢动。实际上金仓的 shared_buffers 管理机制是相对保守的,在64GB以下基本不会出问题。我把值直接调到了64GB。

shared_buffers = 64GB

重启后观察了两天。Buffer Hit%从85%一路升到了97%。 DataFileRead 的等待占比从43%降到了11%。早高峰的CPU使用率从95%降到了60%多——因为CPU不用再花那么多时间等待IO了。

但内存调优远不止一个 shared_buffers

work_mem 是另一个关键参数。默认1MB,对于大多数排序操作来说太小了。排序数据超了 work_mem 就会写到临时文件。金仓的临时文件IO是走磁盘的,堆多了之后IO延迟直线上升。

检查了一下 log_temp_files 的日志,发现每天有上千条临时文件写入记录,最大的排序超过了500MB。1MB的 work_mem 面对500MB的排序,要写磁盘500次,能不慢吗?

当时我把 work_mem 从1MB调到了32MB。但不敢调太高——这个参数是会话级的,600个并发连接,每个会话做排序就分配32MB,光 work_mem 的理论上限就是600×32MB≈19GB。如果再算上 shared_buffers 的64GB和其他开销,总内存会吃紧。

work_mem = 32MB

调完后再看临时文件日志,写入量减少了约80%。那些大量排序的报表查询从原来的20多秒降到了3-4秒。不过我也留了个心眼——针对某些特定的大排序SQL,单独用 SET LOCAL work_mem 在会话级放大,而不是全局性地把值设得太高。

调内存这件事,最核心的原则就一句话:让热数据留在内存里,别让磁盘扛缓存的活。 金仓在这方面没有太多玄学, shared_buffers 给够、 work_mem 按业务场景配、 effective_cache_size 设对,大部分问题就解决了一半。

后来KDDM的GUC建议工具跑了一遍,给我的推荐值和我的调整基本吻合:

SELECT * FROM perf.kddm_guc_advisor(600, 'OLTP', 128, 256);

输出里 shared_buffers 建议64GB、 work_mem 建议55MB、 wal_buffers 建议16MB。我自己的 work_mem 设得保守了些,主要考虑到业务高峰期有大量并发排序操作,安全第一。

三、IO瓶颈:不是硬件不行,是用法不对

内存调完以后,IO等待虽然降了,但没有完全消失。深夜批处理时段,磁盘 %util 仍然频繁打到100%。

iostat -x 1 看,发现一个现象: await 响应时间平均在3-5ms左右,但 svctm 只有0.3ms。 await 远超 svctm 意味着IO请求在排队,等的时间比实际执行的时间还长。再看 avgqu-sz,平均队列深度到了20多。

全闪存储,理论IOPS几万,不应该排这么深的队。肯定是发IO的方式有问题。

定位到具体的SQL。KWR报告的"Top SQL by IO"里,有一条凌晨跑的数据归档SQL,它的 shared_blks_read(共享缓存块读取)是第二名的5倍多。拿出来看执行计划,是一个八张大表关联的批量插入查询,执行计划里有三个全表扫描,扫描的数据量加起来有2亿行。

全表扫描意味着大量的连续IO。如果IO调度策略不对,连续IO和随机IO混在一起互相干扰,队列就排起来了。

当时服务器用的IO调度器是 cfq(完全公平队列),这是很多Linux发行版的默认调度器。 cfq 会尽量公平地为每个进程分配IO带宽,听起来很公平,但在数据库场景下,这种"公平"反而有害——全表扫描的批量操作会拿到和在线交易同样多的IO时间片,导致交易SQL的随机读被批量SQL拖慢。

改成了 deadline 调度器:

echo deadline > /sys/block/sda/queue/scheduler

这是一个临时调整,重启后会失效。为了持久化,在 /etc/grub.conf 里加了 elevator=deadline。不过金仓官方文档里也提到,如果底层是SAN或者高端全闪阵列,用 noop 可能更好,让存储自己管理IO排队。

除了IO调度器,文件系统的挂载参数也是个坑。检查了一下 /etc/fstab,挂载参数是默认的 rw,relatimerelatime 虽然比 atime 好,但每次读操作还是会更新文件的访问时间元数据,在数据库这种高频读写场景下,这个操作本身就会产生额外IO。

改成了金仓推荐的全闪存配置:

/dev/sdb1 /data xfs rw,noatime,nodiratime,nobarrier 0 0

noatime 完全禁用访问时间更新, nobarrier 在有电池保护的RAID卡或全闪存上可以安全使用,能减少IO屏障的开销。改完之后重新挂载, %iowait 又降了一截。

还有一件事让我印象挺深的:数据文件和WAL日志在同一个卷上。这在早期的服务器规划里可能是图省事,但在高并发场景下就是灾难——数据写入和日志写入争同一块盘的IO。我后来在存储上划了两个卷,把WAL日志单独迁移到了新卷。迁移过程不算复杂:停应用、拷贝WAL目录、改符号链接、启动。重启之后观察了两次批量操作,IO等待的峰值下降了约30%。

这个阶段最大的体会是:IO瓶颈不一定是硬件不够。很多时候是软件层面没把硬件用好。IO调度器、文件系统参数、数据与日志分离,这些都是零成本的改动,效果却非常直接。

四、锁与阻塞:最隐蔽的杀手

内存和IO基本稳住之后,系统很少再出现大面积的响应超时了。但有个业务模块的故障还在反复出现——每天早上9点左右,某个审批流接口会卡住5-8分钟,然后恢复。系统日志里没有任何错误,就是单纯的慢。

这种间歇性故障最烦人。重启应用就好了,明天同一时间又卡。我开始怀疑是锁的问题。

锁的问题有个特点:生产环境出现的时候你来不及抓现场,等你去查的时候锁可能已经释放了。所以得提前布好监控。

我在KWR配置里开启了更详细的锁统计,然后在 sys_stat_activitysys_locks 上写了一个持续监控脚本,每5秒采样一次会话信息,记录那些wait_event不是"None"的会话。

第二天卡顿的时候,监控脚本正好抓到了现场。一个会话的 wait_event_typeLockwait_eventtransactionidstateactive。它在等一个事务锁。而这个锁被另一个会话持有了,那个会话的 stateidle in transaction,已经空转了将近20分钟。

再看那个空转会话执行的SQL——是一条 UPDATE 语句,它锁住了一行数据后,事务一直没有提交或回滚。后面的审批流接口需要更新同一行数据,就卡在那里了。

-- 阻塞者(idle in transaction)BEGIN;UPDATE biz_approval SET status = 'processing' WHERE id = 'A001';-- 然后...就没有然后了,20分钟没有commit-- 被阻塞者UPDATE biz_approval SET status = 'approved', approver = '张三' WHERE id = 'A001';-- 卡住,等锁

找到根因就好办了。定位到阻塞会话的pid后,用了两种手段:

  1. 短期解决:杀掉空转会话( sys_terminate_backend(pid)),被阻塞的SQL立即执行完毕。
  2. 长期解决:找到业务代码里那个没有 commit 的分支,发现是开发在异常处理里漏写了事务回滚。补上之后,第二天同一时间再也没有卡顿。

但事情没有就此结束。KWR报告里的锁数据让我发现了一个更大的隐患: transactionid 等待事件占DB Time的比例在批处理时段高达4.3%。虽然没有到报警的程度,但趋势是上升的。

深入看 sys_locks 数据,发现核心业务表 biz_orders 上经常出现大量的 RowExclusiveLock 冲突。原因是一天中有多个批处理任务会同时更新这张表——订单状态变更、财务对账、库存回写,三个不同的应用模块各跑各的,都集中在凌晨1点到3点。

排查方法:先看哪些会话在等待锁。

SELECT blocked_locks.pid         AS blocked_pid,
       blocked_activity.usename  AS blocked_user,
       blocking_locks.pid        AS blocking_pid,
       blocking_activity.usename AS blocking_user,
       blocked_activity.query    AS blocked_statement,
       blocking_activity.query   AS current_statement_in_blocking_processFROM  sys_locks blocked_locksJOIN  sys_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype                              AND blocked_locks.database = blocking_locks.database                              AND blocked_locks.relation = blocking_locks.relation                              AND blocked_locks.page = blocking_locks.page                              AND blocked_locks.tuple = blocking_locks.tupleJOIN  sys_stat_activity blocked_activity  ON blocked_locks.pid = blocked_activity.pidJOIN  sys_stat_activity blocking_activity ON blocking_locks.pid = blocking_activity.pidWHERE NOT blocked_locks.granted;

这条SQL能直观地看到"谁在等谁"。跑出来的结果让我哭笑不得——凌晨2点10分,一个跑了45分钟的事务锁着 biz_orders 表的几行记录不放,后面三个任务排队等着更新同一个主键范围内的数据。整个批处理流水线因为这个阻塞全部后延,最终导致早高峰开始时批处理还没跑完。

解决方案分两步走:

第一步是止损。和业务方沟通,重新安排了批处理的执行顺序—— UPDATE 频率高的任务先跑,低频率的后跑,避免同时操作同一批数据。

第二步是优化代码。开发把三个批处理里的大事务做了拆分,每个事务只处理500条记录就提交一次,减少单次事务的锁持有时间。改完以后, transactionid 等待占DB Time的比例从4.3%降到了0.8%。

不过,还有个更隐蔽的锁问题—— extend 等待。KWR报告的Top 10等待事件里,有一个 extend 平均等待时间17ms。一开始我没太在意,觉得17ms不算长。但仔细一想不对,这是一个普通的文件扩展操作,理论上应该在微秒级完成,17ms说明扩展操作经常在等待IO。

查了文档, extend 等待发生在数据文件需要扩展新空间时。如果表是频繁插入的热点表,数据文件不断增长,每次扩展都要同步写磁盘,并发插入就会互相等。金仓官方建议对这类表做预扩展:

ALTER TABLE biz_orders SET (FILLFACTOR = 80);

FILLFACTOR 设为80,让每页预留20%的空间给后续的 UPDATE 使用,减少因页面分裂触发的文件扩展频率。同时调整了 autovacuum 的阈值,让垃圾回收更积极一些。

改完之后, extend 等待消失了。

锁的问题排查起来不如CPU和IO那么直观,但它的破坏性往往更大——一个不提交的事务能卡住整个业务线。团队后来定了个规矩:任何事务操作代码必须经过review,确保所有分支都有commit或rollback,不允许出现 idle in transaction 超过30秒的情况。

五、系统参数的"组合拳"

内存、IO、锁都调了一遍,系统的整体表现已经比接手的时候好太多了。但我是个贪心的人,总觉得还有优化空间。

这次我把目光放在了几个系统级参数上,用KDDM的调优报告做参考。

KDDM是一个挺实用的工具,它基于两个KWR快照的对比,自动分析瓶颈并给出建议。

SELECT * FROM perf.create_snapshot();  -- 调优前快照-- ... 等待一段时间 ...SELECT * FROM perf.create_snapshot();  -- 调优后快照SELECT * FROM perf.kddm_report(1, 2);

输出报告里列出了好几条建议,优先级最高的一条是: 调整检查点参数以减少IO尖峰

检查点是数据库定期把脏页写到磁盘的过程。默认的 checkpoint_timeout 是5分钟, max_wal_size 是1GB。在这个配置下,每5分钟会触发一次检查点,如果这5分钟内WAL日志超过了1GB,就会提前触发。在批处理高峰期,5分钟产生2-3GB的WAL日志是常事,于是检查点几乎一直在跑,IO尖峰此起彼伏。

我把 checkpoint_timeout 调到15分钟, max_wal_size 调到8GB:

checkpoint_timeout = 15minmax_wal_size = 8GBcheckpoint_completion_target = 0.9

checkpoint_completion_target = 0.9 的意思是,检查点写入在超时时间的90%内尽量平滑完成,而不是一次性爆发写入。这样一来,脏页写入被分散到了15分钟的时间窗口里,磁盘IO曲线变得平滑多了。

然后是后台写进程。金仓的 bgwriter 负责在检查点之间把脏页逐步写回磁盘,减少检查点的负担。原来的 bgwriter_delay 是200ms,意味着每200ms才刷一次脏页。对于高吞吐量的系统来说,这个频率太低,脏页堆积太快。

bgwriter_delay = 10msbgwriter_lru_maxpages = 200bgwriter_lru_multiplier = 4.0

改完之后,KWR的检查点统计里,"脏页写入总量"没太大变化,但"每次检查点需要写的脏页数"明显下降了——因为bgwriter在日常就把大部分脏页处理掉了,检查点只需做最后的收尾。

还有一处优化是关于WAL日志的。KWR报告的等待事件统计里, wal_insert 一直在前五,占总等待的6%左右。金仓V9提供了一个参数来处理这个场景:

enable_xlog_insert_lock_free = on

这个参数开启后会降低WAL写入时的锁竞争。但有个副作用: commit_delaycommit_siblings 会失效。对于不需要延迟提交的业务场景来说可以接受。开启后, wal_insert 等待基本消失了。

系统参数调整是一个"组合拳"——单个参数动一两下看不出多大效果,但和相关参数配合好了,效果是乘数级的。我把调整前后的KWR报告导出做了一组对比,结果如下:

指标 调整前 调整后
Buffer Hit% 97% 99%
DataFileRead 等待占比 11% 3%
wal_insert 等待占比 6% 0.5%
checkpoint 触发频率 每5分钟 每12-15分钟
磁盘 %util 峰值 85% 45%
平均DB Time/事务 3.2ms 0.8ms

这组数据是在参数调整稳定运行一周后采集的。系统已经有了明显改善,但要说最直观的变化还是业务侧的感受——再也没有人抱怨系统卡了。

六、避坑清单

两个月的调优过程不是一帆风顺的,有好几次差点把系统搞出问题来。记录几条踩过的坑,以后回头看能长点记性。

第一个坑:改参数之前没有记原始值。 有一次调 max_wal_size 调得太大了(设了32GB),检查点间隔太长,CIT故障时恢复时间估计要好几个小时。想改回去的时候发现不记得原始值是多少了。后来所有修改都先在文档里记一笔:参数名、原始值、新值、改的原因、预期效果。两个多月下来,这个文档成了项目最宝贵的资产之一。

第二个坑:大批量调完参数后没有分类验证。 一开始觉得"参数改得多,效果应该更好",一次性改了十几个参数重启。结果系统确实变快了,但到底是哪个参数的功劳完全分不清。后续做类似项目就没法复用经验。后来调整了策略:每次只改1-2个相关参数,观察1-2天,确认效果后再改下一组。

第三个坑: work_mem 调高没算并发。 有次把 work_mem 从默认的4MB直接调到128MB,然后启动了500个并发连接做压力测试。很快OOM了,数据库直接被系统kill掉。幸好测试库,生产环境要是这样搞就出大事了。后来总结了一个简单的计算公式: work_mem × 预期并发排序连接数 ≤ (物理内存 × 0.3)。剩余内存留给 shared_buffers、操作系统缓存和其他进程。

第四个坑:改 checkpoint_completion_target 没观察。 有次把这个值从0.5改到了0.95,想把检查点写得尽量平滑。改完后IO曲线确实平了,但突然一次断电恢复后,数据库恢复到一致性状态花了一个多小时。因为这个值设得越大,检查点间隔内的脏页越多,CIT时需要重做的WAL日志就越多。后来我把它控制在了0.9以下,量力而行。

第五个坑:以为调完就完事了。 刚调完的那两周,每天看KWR报告,各项指标都很漂亮,觉得系统稳了。一个月后某天突然发现Buffer Hit%又掉到了89%。一查,原来数据量又涨了一倍,64GB的 shared_buffers 已经装不下热数据了。没有一劳永逸的调优,监控和持续调整才是常态。

七、方法论:怎么系统地排查一个问题

写了这么多案例和细节,最后整理一下排查系统性能问题时的工作方法。

我现在处理一个性能问题,基本按这个顺序来:

第一步:看影响范围。 是整个库都慢还是只有某个模块慢?是持续慢还是间歇性慢?这个信息决定了判断方向。整个库慢往往是系统资源瓶颈(CPU/IO/内存),局部慢通常是锁或SQL问题。

第二步:看KWR报告。 重点看三块——负载性能表(DB Time高不高)、Top 10前台等待事件(资源花在哪了)、实例效率百分比(缓存命中率够不够)。这三块看完,问题的大致方向就出来了。

第三步:用KSH抓现场。 KWR是分钟的粒度,对于锁等待这类秒级变化不敏感。KSH每秒采样一次会话信息,能在事后复现"那个时刻数据库在干什么"。很多间歇性阻塞问题就是靠KSH的秒级采样才找到根源的。

第四步:缩小范围。 定位到具体的等待事件后,关联到TOP SQL或TOP会话,然后看执行计划、看参数配置、看表结构,层层收窄。

第五步:动手改,但要小步走。 一次只改1-2个参数,改完等足够长的时间观察效果,确认正向后再继续。不确定的参数在测试环境模拟验证。

这个流程听着简单,实际执行起来最难的是第二步——理解等待事件。金仓有几十种等待事件,每种背后对应的原因都不一样。 DataFileRead 高可能是 shared_buffers 不够,也可能是索引缺失导致的全表扫描太多; transactionid 高可能是锁冲突,也可能是业务代码没提交。要分辨这些,就得把等待事件和执行计划、参数配置对照着看。

还有一条经验:不要一上来就调参数。很多性能问题是SQL设计或索引缺失引起的,参数再调也只能缓解不能根治。先查SQL、再看索引、最后才改参数,这个顺序在大多数情况下是适用的。

八、收尾

回过头看这趟调优历程,最大的收获不是把某个参数调到了什么值,而是建立了一套"监控-发现-诊断-解决-验证-归档"的工作闭环。系统参数表、KWR快照、每次变更的记录,这些文档化的东西比参数本身更重要——换个环境、换个人,这套方法论还能继续跑。

前阵子新来的同事问我:“金仓数据库调优最难的是什么?”

我想了想,最难的不是某个参数的坑有多深,而是怎么系统地定位问题。数据库慢的原因有成百上千种,从IO调度器到应用代码事务管理,每一层都可能出问题。没有系统的方法,就只能碰运气,今天调这个明天改那个,永远不知道下一次故障在哪。

我把这两个月的KWR报告全部导出来归档了,留做基线数据。以后不管谁接手这套系统,只要对比一下KWR报告的变化趋势,就能快速判断系统的健康状况。

一个稳定的数据库,不是因为它没有瓶颈,而是瓶颈出现的时候你能及时发现并解决它。


请使用浏览器的分享功能分享到微信等