Cinder Volume Active/Active 支持 - API 竞争

https://blueprints.launchpad.net/cinder/+spec/cinder-volume-active-active-support

目前 cinder-volume 服务只能以 Active/Passive HA 方式运行。

其中一个原因是 c-api 中非原子状态转换,可能导致竞争条件。

本规范提出了一种使用比较和交换在 Cinder API 中进行原子数据库更改的方法,以防止这些情况发生。

问题描述

当前的 Cinder API 服务代码包含一些部分,这些部分首先从数据库中检索内容,然后检查其有效性。如果所有检查都通过,并且操作可以继续,则相应地更改数据库。

检索和数据库更改之间经过的时间取决于验证的性质。在某些情况下,它只是简单地检查“status”字段,而在其他情况下,它需要检查其他表,例如确认卷未附加或没有快照。

API 处理检查和更改的方式为代码竞争的发生创造了机会。当在检索数据库内容之后对数据库进行更改时,就会发生这些竞争。这会导致我们根据过时的数据做出决策,不仅使用过时的引用数据更改数据库,而且在大多数情况下还会错误地调用卷管理器服务来执行操作。

这些 API 竞争的一个例子是 cinder/volume/api.py 中的 extend 方法,我们首先检查状态

if volume[‘status’] != ‘available’

然后稍后更改状态

self.update(context, volume, {‘status’: ‘extending’})

最后,我们发出 RPC 请求来执行操作

self.volume_rpcapi.extend_volume(context, volume, new_size, reservations)

用例

操作员希望避免云中的意外行为,此外,由于这些竞争条件可能导致存储后端的数据损坏。而且,这些情况在可以处理更高工作负载的 Active/Active 配置中发生的可能性更大。

提议的变更

建议的解决方案是使用比较和交换更新,并在发生死锁时进行重试,以确保仅在满足所有必需条件时才更新数据库。

基本上,比较和交换只是一个复杂的数据库查询,该查询在更新查询中添加了一个条件,以确保仅当满足这些条件时才对数据库进行修改。这些条件可以是简单的条件,例如 status == ‘available’,也可以是引用其他表的更复杂的条件。

之所以选择此解决方案而不是其他替代方案,是因为 性能数据 在测试中表明,即使在多主集群数据库配置中最极端的情况下,它们也能产生出色的性能结果。

我们的目标是使 Cinder API 服务行为尽可能接近原始代码,因此我们将会在相同的条件下失败和成功。即使报告的错误会略有不同,但唯一的区别在于我们之前会遇到竞争条件,并且可能导致数据损坏,而现在 API 调用者会收到肯定响应,而其中一个会收到失败,就像两个操作是顺序执行的一样。

我们报告错误的细微差异源于我们不再知道哪个条件触发了失败。因此,为了提高效率和代码清晰度,我们将返回一个通用的错误,说明某些必需条件未满足,因此无法完成请求的操作。

备选方案

一种替代方案是在失败时返回通用错误,该方案由于其复杂性而被拒绝。我们可以从数据库中获取更新的数据,并将其提供给验证方法,该方法将检查哪个条件未满足,并引发带有正确错误消息的正确异常。例如,此方法可以检查卷的 status,如果它不是 available,则引发消息,然后检查该卷是否没有快照,如果有,则引发不同的异常或带有不同消息的相同异常。

过程的框架

result = resource.conditional_update(values, conditions)
if not result:
    resource = db.get_resource(resource.id)
    raise_right_validation_error(resource)
rpc_call(resource)

但是,这里存在潜在的竞争。我们可能会请求一个条件更新,该更新失败(因为数据库中的条件未满足),当我们从数据库中检索数据时,它刚刚更改(现在将满足更新的条件),因此当我们调用负责引发正确验证错误的该方法时,它将找不到任何无法执行操作的原因,因此它将只返回控制权给调用者。

在这种情况下,由于我们没有引发错误,我们将继续进行 rpc 调用而没有更改数据库,这是一个问题。这就是为什么我们需要循环进行条件更新,以确保我们成功更新数据库或无论发生什么竞争条件都引发错误。

更新后的过程框架

while not resource.conditional_update(values, conditions):
    resource = db.get_resource(resource.id)
    raise_right_validation_error(resource)
rpc_call(resource)

在此实现中,最好拥有最大重试次数,而不是一直循环直到我们更新数据库或正确引发错误。这样,如果我们更新条件或验证代码中存在错误,我们将不会陷入无限循环。

还探索并放弃了多种比较和交换的替代方案,因为它们效率较低——即速度较慢或需要更多查询数据库。

被放弃的替代方案是

  • SELECT … FOR UPDATE,并在发生死锁时进行重试,尽管它适用于多主配置,但它比建议的解决方案有更多的死锁重试。

  • 使用具有不同后端的分布式锁定管理器来强制对资源的独占访问。

有关测试的更多信息,请参见 性能数据

数据模型影响

REST API 影响

安全影响

通知影响

其他最终用户影响

性能影响

性能影响可以忽略不计,甚至可能获得更好的性能,因为只会对数据库进行一次查询,而不是多次查询。例如,在卷删除时,我们现在有一个查询来查看状态,另一个查询来获取该卷的快照计数。使用新的比较和交换,我们将只有 1 个查询,如果状态正确且不存在快照,则将更新数据库。

其他部署者影响

开发人员影响

对 Cinder API 方法进行更改后,添加的所有新的 API 方法都需要符合这种新的比较和交换方式来更新数据库,以防止新的竞争条件进入代码库。

实现

负责人

主要负责人

Gorka Eguileor (geguileo)

其他贡献者

欢迎任何人提供帮助

工作项

  • 数据库原子更新方法。

此方法需要某些功能

  • 基本比较:status = ‘available’ status != ‘available’

  • 比较多个值:status in [‘available’, ‘error’] status not in [‘attaching’, ‘detaching’]

  • 处理 None 值,就像 Python 所做的那样:在对非 None 值进行否定比较时,它也应返回 None 值。因此,检查 ‘migration_status’ != ‘migrating’ 也会返回 ‘migration_status’ 设置为 None 的值。

  • 使用其他复杂的过滤器:在某些情况下,例如检查附件,可能需要更复杂的查询。

  • 根据其他字段设置更新值。例如,在分离卷时,如果该卷没有更多的卷附件,则必须将状态设置为“available”,如果还有更多的附件(它是多附件的),则必须将其设置为“in-use”。

  • 根据数据库中的字段设置更新值:previous_status = status, status = ‘retyping’ size = size + 10

  • 版本化对象中的原子更新方法。

除了公开上述数据库条件更新项目提供的功能外,它还需要

  • 自动将资源的 ID 添加到条件更新中。

  • 如果未提供任何条件,则必须假设我们希望数据库记录保持不变。

  • 它必须允许与更新一起保存脏属性。

  • 它必须能够使用写入数据库的数据更新版本化对象,即使是条件值。

  • 更新 Cinder API 服务方法以使用比较和交换。

依赖项

没有 openstack 依赖项,但它具有 SQLAlchemy 依赖项,版本为 1.0.10,其中包括参数排序更新 (issue #3541)。

在某些数据库中,更新方法是顺序相关的,因此它们根据值的顺序表现不同,例如在状态为“available”的卷上

UPDATE volumes SET previous_status=status, status=’retyping’ WHERE id=’44f284f9-877d-4fce-9eb4-67a052410054’;

将导致状态为“retyping”且 previous_status 为“available”的卷,在 SQLite 和 MariaDB 上都是如此,但是

UPDATE volumes SET status=’retyping’, previous_status=status WHERE id=’44f284f9-877d-4fce-9eb4-67a052410054’;

将产生 SQLite 中的相同结果,但在 MariaDB 中将导致状态和 previous_status 都设置为“retyping”的卷,这不是我们想要的,因此必须考虑顺序。

测试

将为原子更新方法添加单元测试。

文档影响

参考资料