被长期低估的一致性:拆服务之前先算清 ACID 的账
作者指出,微服务与非 ACID 数据库流行之际,最容易被忽略的代价是 ACID 事务。文章以「一个用户必须有一个 profile」为约束,展示单库事务如何同时保证原子性与隔离性:创建、外键校验都在一个事务内完成。一旦把 users 与 profiles 拆成两个服务,原子性、隔离性、外键约束全部失效。作者逐一推演失败路径:profile 服务失败会留下裸 user;补写的 delete 回滚本身会失败,或进程断电留下中间态;即使两个服务 100% 可靠,并发线程仍会看到 user 无 profile 并重复创建;外键失效后 profile 可指向已删除或改过的 user ID;定时清理 dangling 数据也仍存在不一致窗口。结论是应用层的补偿逻辑最终等价于重造一个半成品、带 bug 的分布式 ACID 数据库,采用微服务或 NoSQL 之前应先理解自己引入了什么复杂度。适合准备拆分服务或迁移 NoSQL 的后端工程师。
17 September 2016
Micro-services and non-ACID databases have been trendy over the last few years due mainly to their success at large companies like Google. I’m not going to tell you not to use them, but I am going to try to explain one of the things you should understand well before you do.
2016 年 9 月 17 日
过去几年,微服务和非 ACID 数据库一直很流行,主要是因为它们像 Google 这样的大公司里取得了成功。我不是要劝你别用,而是想解释一件你在用之前应该先弄清楚的事。
One of the most important things you lose in both cases are ACID transactions. Transactions have been around so long and are so mundane that I think many developers have forgotten why they are so useful.
这两种选择都会让你失去一项最重要的东西:ACID 事务。事务存在得太久、太寻常,以至于我认为很多开发者已经忘了它为什么这么有用。
Example: User Profiles
We have a constraint: one user must have one profile. Our application code assumes that there cannot be users without a profile or profiles without a user. If this doesn’t hold then our data is in an inconsistent state and our application can fail.
示例:用户资料
我们有一个约束:一个用户必须对应一份资料。应用代码假定不会出现没有资料的用户,也不会出现没有用户的资料。一旦这个约束不成立,数据就处于不一致状态,应用可能会失败。
With transactions you can create them both together like in this pseudo-Python:
# Either a user and profile are created together, or nothing is created
with atomic_transaction():
user = User.create(user_data)
Profile.create(user, profile_data)
No problem!
Atomicity (The ‘A’ in ACID) ensures that our constraint is always in force. The user and profile will either be created together, or in the case of failure, neither will be.
Isolation (The ‘I’ in ACID) ensures that any other threads or processes querying the database will never see a user without its corresponding profile.
有了事务,你可以像下面这段伪 Python 代码一样,把两者一起创建:
# Either a user and profile are created together, or nothing is created
with atomic_transaction():
user = User.create(user_data)
Profile.create(user, profile_data)
没问题!
原子性(ACID 中的 A)保证约束始终生效。用户和资料要么一起创建,要么在失败时都不创建。
隔离性(ACID 中的 I)保证其他线程或进程查询数据库时,绝不会看到某个用户缺少对应的资料。
Additionally, with the use of foreign keys we can also ensure that when we delete or edit a profile or user, that there is a valid reference after every transaction. This is ACID consistency (note: not CAP consistency).
此外,借助外键,我们还能保证在删除或编辑资料/用户之后,每个事务结束时引用仍然有效。这就是 ACID 一致性(注意:不是 CAP 一致性)。
Losing ACID
It is not uncommon to have a separate service for user login functionality, especially if you want to have the same login information for multiple applications. Let’s say you make the decision to split users and profiles in to two services. Now we lose our ACID guarantees. What does this mean?
user = user_service.create(user_data)
profile_service.create(user, profile_data)
The first problem is this is no longer atomic. If the profile service fails, you will have a user with no profile.
失去 ACID
把用户登录功能拆成单独的服务并不少见,尤其是当你想让多个应用共用同一套登录信息时。假设你决定把用户和资料拆到两个服务里。这时我们就失去了 ACID 保证。这意味着什么?
user = user_service.create(user_data)
profile_service.create(user, profile_data)
第一个问题:这不再是原子的。如果资料服务失败,你就会得到一个没有资料的用户。
Let’s make a naive attempt at fixing this by deleting the user if the profile service fails:
user = user_service.create(user_data)
try:
profile_service.create(user, profile_data)
except:
# profile_service has failed, so roll back user creation.
user_service.delete(user)
raise
Seems reasonable, but there are at least two failure cases that can lead to inconsistency:
我们做个朴素的尝试:如果资料服务失败,就删掉用户。
user = user_service.create(user_data)
try:
profile_service.create(user, profile_data)
except:
# profile_service has failed, so roll back user creation.
user_service.delete(user)
raise
看起来合理,但至少有两种失败场景会导致数据不一致:
user = user_service.create(user_data)
# <--- Failure at this point means a user with no profile
try:
profile_service.create(user, profile_data)
except:
# <--- Failure of user_service here means a user with no profile
user_service.delete(user)
raise
First, if your program completely fails after the user_service call (somebody pulled the power cord), your data will be in an inconsistent state. Secondly, if your user_service fails on the delete, your data will also be in an inconsistent state.
user = user_service.create(user_data)
# <--- Failure at this point means a user with no profile
try:
profile_service.create(user, profile_data)
except:
# <--- Failure of user_service here means a user with no profile
user_service.delete(user)
raise
第一,如果程序在调用 user_service 之后彻底失败(比如有人拔了电源线),数据就会处于不一致状态。第二,如果 user_service 在删除用户时失败,数据同样会不一致。
Another strategy might be to attempt to access a user’s profile, and create one if it doesn’t exist. Unfortunately, even if your services are 100% reliable this is not isolated. In a concurrent system other threads and processes can see the system in an inconsistent state:
user = user_service.create(user_data)
# <--- A process or thread executing at this point will
# see the user but not the profile, and maybe try to
# create a new one, leaving us with two profiles.
profile_service.create(user, profile_data)
另一种策略是:访问用户资料时,如果不存在就创建一份。但不幸的是,即使服务 100% 可靠,这种做法也没有隔离性。在并发系统中,其他线程和进程会看到系统处于不一致状态:
user = user_service.create(user_data)
# <--- A process or thread executing at this point will
# see the user but not the profile, and maybe try to
# create a new one, leaving us with two profiles.
profile_service.create(user, profile_data)
Yet another issue is editing or deleting users or profiles. We no longer have our foreign key constraint enforced, so now it’s possible for a profile to refer to a user ID that no longer exists, or has been changed in one service but not the other.
Even if you try to compensate for this, for example with a periodic task that cleans up dangling profiles, there will be at least some time where your data is inconsistent.
还有一个问题是编辑或删除用户/资料。外键约束不再强制执行,因此资料可能引用一个已不存在的用户 ID,或者用户 ID 在一个服务里改了、在另一个服务里没改。
即使你试图补偿,比如用定时任务清理悬空资料,也总会有一段时间数据是不一致的。
Conclusion
As we can see, compensating for the loss of ACID is not an easy task.
You may be able to think of other strategies not covered here, but these kinds of workarounds in the application layer are not going to work short of recreating a half-working, buggy version of a distributed ACID database. Distributed data is a fundamentally difficult problem, and you should not take it on without understanding the complexity you are introducing.
If you decide to use micro-services or NoSQL databases, do your homework!
结论
如我们所见,补偿 ACID 的缺失绝非易事。
你也许能想出本文没提到的其他策略,但应用层这类变通做法,最终只会重造出一个半吊子、满是 bug 的分布式 ACID 数据库。分布式数据本质上就是个难题,不了解自己引入的复杂度,就不该贸然接手。
如果你决定使用微服务或 NoSQL 数据库,请先做好功课!
Further Reading
Wikipedia’s entry on ACID
CAP consistency vs. ACID consistency
PostgreSQL documentation on isolation levels
How CockroachDB does distributed transactions
Google’s ‘Spanner’ paper
延伸阅读
维基百科的 ACID 条目
CAP 一致性与 ACID 一致性
PostgreSQL 隔离级别文档
CockroachDB 如何实现分布式事务
Google 的 ‘Spanner’ 论文
Errata and Feedback
On Hacker News vidarh holds the opinion that consistency is overvalued. I’d like to emphasise that I’m not against using non-ACID or distributed systems as long as the resulting problems are well understood. I’d also like to say that if you have the option, using an ACID database is usually the solution easiest to get correct.
Thorsten Möller mentions (via email) that it is important to understand isolation, the ‘I’ of ACID. Even ACID databases do not always fully enforce isolation in their default configuration. Check out the PostgreSQL documentation for more information on isolation levels.
On 16 July 2017, I significantly reworked the article to be more precise and concise.
勘误与反馈
在 Hacker News 上,vidarh 认为一致性被高估了。我想强调,我并不反对使用非 ACID 或分布式系统,前提是清楚理解由此带来的问题。我还想说,如果有得选,使用 ACID 数据库通常是最容易做对的方案。
Thorsten Möller 通过邮件提到,理解隔离性——ACID 中的 I——非常重要。即使是 ACID 数据库,默认配置下也不总能完全保证隔离性。欲了解更多隔离级别的信息,请参阅 PostgreSQL 文档。
2017 年 7 月 16 日,我对文章做了大幅改写,使其更精确、更简洁。