GBase 8c
其他
文章
精选

GBase 8c 同一事务两次查询结果不一致的排查

发表于2026-04-02 10:06:29161次浏览3个评论

GBase 8c 同一事务两次查询结果不一致的排查

我最近整理 GBase 8c 事务这块资料时,越来越觉得一个很常见的现场问题经常被误判:业务明明已经“开了事务”,可同一段处理逻辑里前后两次查询结果还是不一样。很多人第一反应会怀疑缓存、主备切换、驱动自动重连,甚至会往数据同步异常上想。但我自己理解下来,这类问题里相当一部分并不是故障,而是事务隔离级别、快照时机和显式事务边界没有对齐。

真正落到现场时,我一般先不急着追日志,也不急着扣“数据库不一致”这个帽子,而是先确认三件事:是不是显式事务、当前隔离级别是什么、业务要的到底是“读到最新已提交结果”,还是“同一事务内读到稳定快照”。这三件事如果没先捋顺,后面的排查基本都会越看越乱。

这篇我想聚焦一个更容易落地的角度:在 GBase 8c 里,为什么同一事务里两次 SELECT 可能不一样;什么情况下这是正常行为;什么情况下应该把事务改成 REPEATABLE READ;以及改完以后为什么应用侧还得补一层重试逻辑。

我排查这类问题时,先看哪几项

很多现场描述其实都很像,但根因并不完全一样。我最近整理下来,最容易混在一起的是下面几类:

现场现象 我第一反应会看什么 更常见的原因
同一事务里两次聚合结果不一样 是否显式 BEGIN,隔离级别是否仍是 READ COMMITTED 语句级快照,不是事务级快照
应用说“我都开事务了,为什么还是读到别人刚提交的数据” 是不是只开了事务但没改隔离级别 默认仍可能看到其他事务后续提交结果
两个终端手工测试现象和应用里不一样 应用是否自动提交、连接池是否复用了连接状态 测试方式和真实连接行为不一致
报表校验阶段和最终写回阶段看到的基线不一致 事务持续时间是否过长,是否跨多个服务调用 长事务里读基线漂移,或者快照过旧导致后续重试
以为分布式事务会天然固定读视图 是单 DN 事务还是跨 DN 事务 2PC 解决的是提交原子性,不直接等于固定快照

我个人更关注的其实不是“有没有事务”这句话本身,而是事务边界和读视图边界是不是一回事。在 GBase 8c 里,这两个边界不总是天然重合。

先把几个关键点说透

我自己理解下来,GBase 8c 这里最容易踩坑的有四个基础点。

1. GBase 8c 默认不是事务级固定快照

GBase 8c 支持 READ COMMITTED 和 REPEATABLE READ 两种常用隔离级别。READ COMMITTED 是默认值,这个级别下每条新语句都会基于语句开始时的快照读数据。也就是说,同一个事务里的两条相邻 SELECT,如果中间有别的事务提交了修改,第二条语句看到的新结果是正常的。

这也是为什么有些业务会说:“我明明在一个事务里,怎么前后两次 count 不一样?”从数据库行为上看,它并不一定错,只是它给的是语句级一致性,不是整个事务期间的一致性视图。

2. REPEATABLE READ 才更接近很多人脑子里的“事务内稳定视图”

如果事务隔离级别改成 REPEATABLE READ,那么事务里的查询看到的是事务开始时的快照,而不是每条语句开始时的快照。这样一来,只要事务还没结束,同一事务里的多次查询会尽量基于同一份视图。

但这里不能只看到“稳定”,还要看到代价。GBase 8c 的资料里也明确提醒,使用这个级别时,应用需要准备好重试,因为可能发生串行化失败。换句话说,你是拿更稳定的读视图,去换更高的冲突处理成本。

3. GBase 8c 的事务并发不是“全靠锁顶住”

我最近看资料时,一个比较重要的点是:GBase 8c 在事务管理上采用 MVCC 结合两阶段锁 的方式,核心特点是读写之间通常不互相阻塞。这个认知很重要,因为很多人一提事务就只想到锁等待,结果把“快照读差异”误排成“锁冲突”。

从落地角度看,读写不阻塞 不代表 多次读取一定得到同一结果。如果隔离级别仍是 READ COMMITTED,那读到不同结果仍然是预期内行为。

4. 分布式事务的 2PC 解决的是提交一致,不是读视图时机

GBase 8c 作为分布式数据库,跨多个 DN 的事务会走两阶段提交,目的是避免部分节点提交、部分节点回滚的“中间态”。这个能力非常关键,但它主要解决的是分布式提交原子性。

真正落到现场时,我会特别提醒自己不要把这两个概念混掉:

  • 2PC 关注的是跨节点提交结果是否一致。
  • 隔离级别 / 快照时机 关注的是事务在执行期间如何看见数据。

也就是说,即便你的事务是严格原子提交的,也不意味着它在 READ COMMITTED 下会天然拥有一份整个事务周期稳定不变的读视图。

两种隔离级别,我一般怎么理解

对比项 READ COMMITTED REPEATABLE READ
默认级别 是 否
快照生成时机 每条语句开始时 事务开始时
同一事务两次查询结果可能不同吗 可能 通常不会因为其他事务提交而变化
能否看到本事务自己前面未提交的修改 可以 可以
更适合的场景 高频 OLTP、希望尽快看见最新已提交结果 对账、校验、结算、同事务多次比对
主要代价 读视图可能漂移 可能需要重试事务

我自己更倾向于这样记:

  • 业务要“尽快看到别人已提交的最新结果”,优先考虑 READ COMMITTED。
  • 业务要“整个事务期间按同一基线做判断”,再考虑 REPEATABLE READ。

这不是哪个更高级的问题,而是业务目标不同。

我在现场常用的一组复现实验

下面这组示例我觉得很适合拿来和开发、测试一起对齐认知。为了更接近真实环境,我用 gsql 连到 CN 入口做演示。

gsql -h 192.0.2.10 -p 5432 -d retaildb -U app_user

先准备一张简单的库存表:

CREATE SCHEMA IF NOT EXISTS lab_txn;
SET search_path TO lab_txn;

DROP TABLE IF EXISTS t_inventory;
CREATE TABLE t_inventory (
    sku_id       bigint PRIMARY KEY,
    stock_qty    integer NOT NULL,
    update_time  timestamp NOT NULL DEFAULT now()
);

INSERT INTO t_inventory (sku_id, stock_qty) VALUES
(10001, 50),
(10002, 80);

场景一:READ COMMITTED 下两次查询结果发生变化

会话 A

BEGIN;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

SELECT stock_qty FROM t_inventory WHERE sku_id = 10001;
-- 第一次查到 50

-- 此时先不要提交,等待会话 B 完成更新
SELECT stock_qty FROM t_inventory WHERE sku_id = 10001;
-- 第二次可能看到 45

COMMIT;

会话 B

BEGIN;
UPDATE t_inventory
   SET stock_qty = stock_qty - 5,
       update_time = now()
 WHERE sku_id = 10001;
COMMIT;

这个现象如果出现在 READ COMMITTED 下,我一般不会把它判成异常。它更像是业务预期和隔离级别没对齐。

场景二:REPEATABLE READ 下保持事务内基线稳定

先把数据恢复一下:

UPDATE t_inventory
   SET stock_qty = CASE sku_id
                     WHEN 10001 THEN 50
                     WHEN 10002 THEN 80
                   END,
       update_time = now();
COMMIT;

会话 A

BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT sum(stock_qty) AS total_qty FROM t_inventory;
-- 第一次查到 130

-- 等待会话 B 提交
SELECT sum(stock_qty) AS total_qty FROM t_inventory;
-- 仍然看到 130

COMMIT;

会话 B

BEGIN;
UPDATE t_inventory
   SET stock_qty = stock_qty - 10,
       update_time = now()
 WHERE sku_id = 10002;
COMMIT;

这时候会话 B 的修改已经成功提交,但会话 A 在自己的事务里仍然维持原先那份读视图。对需要“先核对、再落账”的处理流程来说,这种一致性通常更好用。

场景三:改成 REPEATABLE READ 以后,应用侧要准备重试

很多问题到这里还没结束。有人会说,那我全部改成 REPEATABLE READ 不就行了?

我自己一般不会这么建议。因为一旦事务里既有读校验,又有后续更新,而且并发还高,冲突并不会凭空消失,它只是从“读视图漂移”变成了“事务可能要重试”。

我更倾向于在应用侧补一层有限次数重试,思路大概像这样:

#!/usr/bin/env bash
DB_HOST=192.0.2.10
DB_PORT=5432
DB_NAME=retaildb
DB_USER=settle_user
SQL_FILE=/opt/app/sql/txn_settle.sql
MAX_RETRY=3
TRY_NO=1

while [ ${TRY_NO} -le ${MAX_RETRY} ]
do
  gsql -h ${DB_HOST} -p ${DB_PORT} -d ${DB_NAME} -U ${DB_USER} -f ${SQL_FILE}
  RC=$?

  if [ ${RC} -eq 0 ]; then
    exit 0
  fi

  sleep $((TRY_NO * 2))
  TRY_NO=$((TRY_NO + 1))
done

exit 1

这段脚本本身不复杂,但它表达的是一个很现实的思路:事务级一致性不是免费午餐,业务必须为冲突后的重试预留处理空间。

真正落地时,我会怎么选

业务场景 我更倾向的做法 主要原因 额外提醒
订单类短事务、实时改库存 READ COMMITTED 更容易拿到最新已提交结果,事务保持短小 不要把校验链路拉太长
对账、结算、核对类处理 REPEATABLE READ 需要同一事务内看到稳定基线 应用必须支持重试
先查一大批数据再逐条更新 优先拆阶段,不要盲目拉长事务 长事务更容易放大冲突和回滚成本 读和写能拆就拆
跨服务调用后再回库更新 尽量避免把外部调用包在事务里 事务时间过长,快照和资源占用都会变差 事务内只做必要数据库动作
只执行单条 SQL 的轻量操作 让语句自动提交即可 GBase 8c 单语句本来就是自动提交事务 不要误以为这也是长事务的一部分

这类问题里最容易踩的几个坑

常见误区 我更建议的理解
只要 BEGIN 了,同一事务查询结果就该固定 只有在合适隔离级别下,事务级稳定视图才成立
分布式事务用了 2PC,就天然不会出现前后读差异 2PC 保证提交原子性,不直接决定快照读取时机
改成 REPEATABLE READ 就万事大吉 一致性更强了,但冲突后的重试成本也上来了
事务隔离级别可以在事务跑了一半再改 第一条数据访问语句执行后,再改就太晚了
手工终端复现没问题,应用里也一定一样 连接池、自动提交、事务包装方式都可能让现象变化

这里我还想额外强调一个很容易被忽略的点:GBase 8c 的单语句查询,在不显式使用 BEGIN 之类事务块时,本身就是自动提交的。也就是说,有些应用嘴上说“这个流程在事务里”,但实际调用链拆开以后,根本不是一个真正连续的事务上下文。

我自己的几条实战建议

第一,先把业务一致性要求翻译成数据库语言。很多需求文档写的是“保证处理期间数据一致”,但没有继续说明它要的是“语句一致”还是“事务一致”。我实际排查时一般先让开发把这个问题回答清楚。

第二,隔离级别不要靠默认值碰运气。只要某条链路对稳定快照有明确要求,我个人更倾向于在事务开头显式设置,而不是假定连接状态永远正确。

第三,不要把长事务当成兜底方案。事务包得越长,冲突、回滚、重试和资源占用就越难看。很多时候真正该改的不是隔离级别,而是处理流程分段方式。

第四,区分“读视图问题”和“锁冲突问题”。前者重点看隔离级别、快照时机和是否显式事务;后者才去看等待链、互斥更新和锁资源。两个方向一混,现场很容易越查越偏。

第五,跨 DN 的分布式事务要同时看两件事:一是提交原子性是否由 2PC 保住了,二是业务读取语义是否与隔离级别匹配。这两个点缺一个,现场解释都不完整。

结尾

我最近看 GBase 8c 事务相关资料时,最大的一个感受是:很多所谓“同一事务里结果不一致”的问题,并不是数据库行为反常,而是业务对事务语义的想象和数据库实际提供的隔离级别没对上。

如果业务只需要读到最新已提交结果,READ COMMITTED 往往更合适;如果业务要求整个事务期间围绕同一份基线做判断,那就该认真评估 REPEATABLE READ,同时把重试机制一起补上。再往前一步说,真正稳妥的做法从来不是只改一个 SQL,而是把事务边界、快照边界、应用重试边界三件事一起设计清楚。

这也是我现在排查这类问题时最先盯住的主线:先确认读视图,再确认事务边界,最后才去看更深层的并发细节。很多问题到这一步,其实就已经解释通了。

参考资料

[1] GBase 8c事务隔离级别
https://www.gbase.cn/community/post/1725

[2] 基本概念 | GBASE南大通用
https://www.gbase.cn/docs/gbase-8c/03%20%E5%BC%80%E5%8F%91%E8%80%85%E6%8C%87%E5%8D%97/%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5

[3] gsql | GBASE南大通用
https://www.gbase.cn/docs/gbase-8c/04%20%E5%B7%A5%E5%85%B7%E5%8F%82%E8%80%83/01%20%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%B7%A5%E5%85%B7/gsql

[4] 南大通用GBase 8c事务状态保持技术解析与实践
https://www.gbase.cn/community/post/3866

[5] 技术白皮书 | GBASE南大通用
https://www.gbase.cn/docs/gbase-8c/%E6%8A%80%E6%9C%AF%E7%99%BD%E7%9A%AE%E4%B9%A6/%E6%8A%80%E6%9C%AF%E7%99%BD%E7%9A%AE%E4%B9%A6

评论

登录后才可以发表评论
用户头像
柒柒天晴发表于 6个月前
哈哈哈哈
GBase用户47954发表于 5个月前
感谢作者的精彩分享!
流泪猫猫头发表于 4个月前
很详细实用的文章