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

张开发
2026/4/5 14:06:57 15 分钟阅读

分享文章

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 COMMITTEDREPEATABLE READ默认级别是否快照生成时机每条语句开始时事务开始时同一事务两次查询结果可能不同吗可能通常不会因为其他事务提交而变化能否看到本事务自己前面未提交的修改可以可以更适合的场景高频 OLTP、希望尽快看见最新已提交结果对账、校验、结算、同事务多次比对主要代价读视图可能漂移可能需要重试事务我自己更倾向于这样记业务要“尽快看到别人已提交的最新结果”优先考虑READ COMMITTED。业务要“整个事务期间按同一基线做判断”再考虑REPEATABLE READ。这不是哪个更高级的问题而是业务目标不同。我在现场常用的一组复现实验下面这组示例我觉得很适合拿来和开发、测试一起对齐认知。为了更接近真实环境我用gsql连到 CN 入口做演示。gsql-h192.0.2.10-p5432-dretaildb-Uapp_user先准备一张简单的库存表CREATESCHEMAIFNOTEXISTSlab_txn;SETsearch_pathTOlab_txn;DROPTABLEIFEXISTSt_inventory;CREATETABLEt_inventory(sku_idbigintPRIMARYKEY,stock_qtyintegerNOTNULL,update_timetimestampNOTNULLDEFAULTnow());INSERTINTOt_inventory(sku_id,stock_qty)VALUES(10001,50),(10002,80);场景一READ COMMITTED下两次查询结果发生变化会话 ABEGIN;SETTRANSACTIONISOLATIONLEVELREADCOMMITTED;SELECTstock_qtyFROMt_inventoryWHEREsku_id10001;-- 第一次查到 50-- 此时先不要提交等待会话 B 完成更新SELECTstock_qtyFROMt_inventoryWHEREsku_id10001;-- 第二次可能看到 45COMMIT;会话 BBEGIN;UPDATEt_inventorySETstock_qtystock_qty-5,update_timenow()WHEREsku_id10001;COMMIT;这个现象如果出现在READ COMMITTED下我一般不会把它判成异常。它更像是业务预期和隔离级别没对齐。场景二REPEATABLE READ下保持事务内基线稳定先把数据恢复一下UPDATEt_inventorySETstock_qtyCASEsku_idWHEN10001THEN50WHEN10002THEN80END,update_timenow();COMMIT;会话 ABEGIN;SETTRANSACTIONISOLATIONLEVELREPEATABLEREAD;SELECTsum(stock_qty)AStotal_qtyFROMt_inventory;-- 第一次查到 130-- 等待会话 B 提交SELECTsum(stock_qty)AStotal_qtyFROMt_inventory;-- 仍然看到 130COMMIT;会话 BBEGIN;UPDATEt_inventorySETstock_qtystock_qty-10,update_timenow()WHEREsku_id10002;COMMIT;这时候会话 B 的修改已经成功提交但会话 A 在自己的事务里仍然维持原先那份读视图。对需要“先核对、再落账”的处理流程来说这种一致性通常更好用。场景三改成REPEATABLE READ以后应用侧要准备重试很多问题到这里还没结束。有人会说那我全部改成REPEATABLE READ不就行了我自己一般不会这么建议。因为一旦事务里既有读校验又有后续更新而且并发还高冲突并不会凭空消失它只是从“读视图漂移”变成了“事务可能要重试”。我更倾向于在应用侧补一层有限次数重试思路大概像这样#!/usr/bin/env bashDB_HOST192.0.2.10DB_PORT5432DB_NAMEretaildbDB_USERsettle_userSQL_FILE/opt/app/sql/txn_settle.sqlMAX_RETRY3TRY_NO1while[${TRY_NO}-le${MAX_RETRY}]dogsql-h${DB_HOST}-p${DB_PORT}-d${DB_NAME}-U${DB_USER}-f${SQL_FILE}RC$?if[${RC}-eq0];thenexit0fisleep$((TRY_NO*2))TRY_NO$((TRY_NO1))doneexit1这段脚本本身不复杂但它表达的是一个很现实的思路事务级一致性不是免费午餐业务必须为冲突后的重试预留处理空间。真正落地时我会怎么选业务场景我更倾向的做法主要原因额外提醒订单类短事务、实时改库存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

更多文章