作者:勿遮 本文来源:PolarDB-X知乎号
从全局二级索引讲起
故事要从全局二级索引开始讲起。 当我们构建了一个全局二级索引之后,一条逻辑上的数据插入,就会变成两条物理上的数据插入:一条插入到主表,另一条插入到索引表。为了保证主表和索引表数据的一致性,我们往往需要开启分布式事务,再并行地插入两条数据。如果其中一条数据插入失败了,比如索引上出现了唯一键冲突,但主表的数据已经插了进去,怎么办呢?当然,我们可以简单粗暴地回滚整个事务,来保证数据的一致性。 但有的时候,我们已经在事务里执行了大量的操作,这时候仅仅因为一条数据的插入失败,就要回滚整个事务,代价实在太大。对于单机 MySQL 来说,如果出现了这种插入 UK 报唯一键冲突的情况,会自动回滚这条插入的语句。至于是忽略报错继续执行事务,还是回滚整个事务,则交给业务方来决定。作为一款全面兼容 MySQL 的分布式数据库,PolarDB-X 自然也要具备这种特性。 其实,不只是全局二级索引的情况,其他场景比如 batch insert/delete/update、广播表 DML 等都可能会遇到这种情况。
聊聊 savepoint
如果要回滚单条或多条语句,而非回滚整个事务,我们自然想到使用 savepoint 这一功能。在事务中,我们可以随时设置一个 savepoint,后续再回滚到这个 savepoint,从而回滚 savepoint 后的所有操作。 MySQL 是如何实现 savepoint 能力的呢? MySQL 在 server 层中,对每个事务对象维护了一个 savepoint 的链表,用于记录这个事务设置过的 savepoint 对象。其中,每个 savepoint 对象主要记录了 savepoint 的名字,用于标识不同的 savepoint 对象。 在设置一个 savepoint 时,会往链表末尾插入一个 savepoint 对象。在释放一个 savepoint 时,会根据 savepoint 名字遍历链表,找到对应的 savepoint 对象,将其及其后面的所有 savepoint 删除。在回滚一个 savepoint 时,会找到对应的 savepoint 对象,根据其存储的信息进行回滚操作,随后,还会隐式释放掉其后的所有 savepoint(不包括它自己)。 可以看到,每个 savepoint 对象都需要存储一定的信息,来告诉 binlog 和 innodb 需要回滚到什么位置。对于 binlog 记录的是设置 savepoint 时的 binlog cache 的 offset;对于 innodb,则是设置 savepoint 时 undo log 的 undo number。这两个简单的信息,就足够 binlog 和 innodb 完成回滚操作了。 事实上,innodb 内部还维护了事务的 savepoint 链表,但本质上和上述说的链表没什么太大差异,就不展开讨论了。
使用 savepoint 解决问题
那 PolarDB-X 该如何使用 DN 的 savepoint 解决一开始提到的全局二级索引的问题呢? 其实做法也很简单,我们只需要在任何物理语句执行之前,加上一个 savepoint,在所有物理语句执行之后,视情况来回滚或是释放 savepoint。我们将这一行为称为 auto-savepoint。 其实,innodb 的行为也是如此,其在每条语句前(实际是上一条语句执行后),会更新一个匿名的 savepoint 对象 last_sql_stat_start,其保存了上一条语句执行后的 undo number。在当前语句执行出错时,通过这个 undo number 来回滚掉这条语句的操作。 熟悉 PolarDB-X 的同学一定知道,PolarDB-X 通过物理连接(计算节点到存储节点的连接)来执行物理 SQL。对于一条逻辑更新 GSI 的 SQL 语句,可能需要使用 2 条物理连接,执行 3 条物理 SQL(一条主表 update,一条 GSI 表删除,一条 GSI 表插入)。如下所示:
物理连接 0(物理分库 0): update primary_tb; insert gsi_tb; 物理连接 1(物理分库 1): delete gsi_tb;
设置 auto-savepoint 的关键就在于要在合适的时机设置 savepoint。在这个例子中,任何一个物理连接执行出错,都会通知其他连接中断其正在执行的操作。假设在物理连接 1 执行 delete gsi_tb 的时候报错了,我们不知道物理连接 0 上的具体执行情况。哪些语句执行成功了、哪些语句执行失败了、哪些语句还没开始执行,我们都不知道。此时,我们可以借助 savepoint 的能力,不管具体的执行情况如何,都统一回滚到一切操作还没开始做的状态,就能达到回滚单条逻辑 SQL 的效果。 因此,我们自动设置的 savepoint 行为就是:
物理连接 0(物理分库 0): savepoint `s0`; update primary_tb; insert gsi_tb; rollback to savepoint `s0`; 物理连接 1(物理分库 1): savepoint `s0`; delete gsi_tb (ERROR); rollback to savepoint `s0`;
当然,这里面的设计还会保证参与了一条逻辑 SQL 的所有物理连接都正确设置上 savepoint,以保证 savepoint 的设置和回滚都不会漏掉,否则就会出现数据不一致的问题了。
代价是什么
我们通过 DN 的 savepoint 能力,来实现 CN 层面上的回滚单条语句的功能。尽管从前面的讨论来看,设置和释放 savepoint 的代价都比较低,只是在链表上新增或删除一个元素,但我们还是需要在实现上尽量减轻这种代价。 首先,我们尽量避免 savepoint 的设置,只在涉及 GSI 或其他逻辑执行的 DML 时,才自动设置 savepoint。因为只有在逻辑执行下,才可能发生分片间不一致的场景,才需要 auto-savepoint 来保证逻辑语句的原子性。其次,我们设置和释放都是通过多语句的方式,将 savepoint 的 SQL 和业务产生的物理 SQL 一并下发,避免增加额外的 RTT。最后,我们还使用了私有协议绕过 savepoint SQL 的解析过程,直接在 DN 上调用设置和释放 savepoint 的代码。
云原生数据库PolarDB分布式版新增标准版形态,基于X-Paxos提供100%兼容MySQL的高可靠性集中式数据库服务。