GBase 8c 同一事务两次查询结果不一致的排查
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
评论
热门帖子
- 12025-12-01浏览数:183632
- 22023-05-09浏览数:26327
- 42023-09-25浏览数:19993
- 52020-05-11浏览数:18704