系统设计中的幂等性
1. 基本概念
幂等性(Idempotence)是系统设计中经常提到的概念。
如果某个操作执行一次或多次都能产生相同的结果,那么它就是幂等的。
2. 代码示例
下面这段代码是幂等的。无论你调用多少次,show_my_button
的最终状态都是False。
def hide_my_button(self):self.show_my_button = False
再看一个例子:
def toggle_my_button_visibility(self):self.show_my_button = not self.show_my_button
这个方法不是幂等的,因为每次调用都会翻转状态:第一次隐藏,第二次又显示。
3. 常见误区
3.1 幂等性与返回值无关
很多初学者会误解幂等性,认为“多次调用返回值相同”才叫幂等。这是错误的,来看一个例子:
def hide_my_button(self):has_something_changed = self.show_my_buttonself.show_my_button = Falsereturn has_something_changed
如果第一次调用时 show_my_button
是 True,返回值是 True;再次调用时返回值变成了 False。但这个方法依然是幂等的,因为无论调用多少次,show_my_button 的最终状态总是 False。幂等性关注的是操作的副作用或系统状态的最终结果,而不是方法的返回值。
3.2 幂等与纯函数
这两个概念也容易混淆,所以简单解释一下。
纯函数:给定相同的输入,总是返回相同的输出,且没有任何副作用。
# 这是一个纯函数。square(3) 将始终是相同的数字。
def square(my_number):return my_number ** 2# 这不是纯函数。square(3) 几乎不会产生相同的结果
def square_with_randomness(my_number):return (my_number ** 2) * random.uniform(0, 1)
幂等函数不一定是纯函数。幂等函数可以有副作用:
# 如果每次保存相同的 name,最终数据库的状态保持一致 → 幂等
def save_name(name):my_database.save(name) # 写入数据库, 有副作用return name
4. 幂等性的问题
幂等设计也可能引入问题:如果某条“毒消息”导致消费者每次崩溃,那么该消息永远留在队列中。于是服务不断重启、崩溃、重试,形成死循环。
解决办法:引入 死信队列(DLQ),将处理失败多次的消息转移到 DLQ,以便后续人工排查。
5. 系统设计中的幂等性
在分布式系统中,网络是不可靠的,服务会失败,消息可能重复投递,所以幂等性是保证数据一致性和系统健壮性的关键。以下场景都依赖幂等性:
- 消息队列的重复消费
- RESTful API 请求重试
- 数据库写入与 UPSERT
- 分布式系统故障恢复
5.1 消息处理
假设我们有一个事件驱动系统:
- Service A 往消息队列里推送事件
- Service B 消费这些事件(假设会执行一些计算操作)并写入数据库
如果Service B在计算过程中崩溃,或者Service B与数据库之间存在网络分区,或其他情况发生,那消息和事件将永远丢失。
解决方案:不立即从队列中删除消息,而是等待Service B完成(包括写入数据库),然后再删除消息。
但这带来了一个新问题:同一条消息可能被读取两次。Service B执行计算并写入数据库,但随后发生了一些情况(比如崩溃)。在消息从队列中删除之前,服务已经崩溃。会发生什么?当 Service B 重启后,将继续从最后那条消息开始消费,因此该消息被消费了两次!但这并不成问题,如果 Service B 的处理逻辑是幂等的,那么即使消息被重复消费,最终结果也是一致的。
缺点是需要一些额外的复杂性(需要保证操作幂等)和一些计算资源(可能不必要地多次执行相同操作)。但这些缺点和丢失消息比起来不值一提。
5.2 API
如果你正在构建REST API,其实已经在处理幂等性了。HTTP协议实际上定义了哪些方法应该是幂等的:
HTTP 方法 | 幂等性 | 说明 |
---|---|---|
GET | 是 | 查询资源,多次调用结果相同 |
PUT | 是 | 完全替换资源,重复 PUT 没有副作用 |
DELETE | 是 | 删除资源,删除已不存在的资源结果也相同 |
POST | 否 | 通常用于创建资源,天然非幂等 |
POST请求:通常在设计上不是幂等的。每个POST通常创建某些内容。但你可以使用幂等键使它们幂等。其工作原理如下:随请求发送一个唯一ID(通常在头中),服务器记住"已经处理过这个ID,因此将返回相同的结果,而不是再次执行工作"。
def create_user(request):idempotency_key = request.headers.get('Idempotency-Key')# 是否已经处理过这个确切的请求?if idempotency_key and already_processed(idempotency_key):return get_cached_response(idempotency_key)# 没有,创建用户user = User.create(request.data)# 缓存响应以备下次使用if idempotency_key:cache_response(idempotency_key, user)return user
5.3 数据库
在数据库操作中,常用的幂等性设计包括:
UPSERT操作(如果存在则INSERT或UPDATE)自然是幂等的。使用相同数据运行update 10次,每次都会得到相同的结果。记录要么创建一次,要么多次更新为相同的值。
5.4 分布式系统
在分布式系统中,幂等性是重试机制的安全基础。
- 网络调用失败可能导致请求被重发
- 微服务之间的链路存在超时重试
- 跨机房、跨服务调用可能会收到重复事件
如果操作不是幂等的,每次重试可能引入数据污染和状态异常。
6. 完整示例:订单处理系统
下面通过一个具体示例将所有内容整合起来。有一个简单的订单处理流水线:订单来自Web应用程序,由订单服务验证,进入队列,然后由订单处理器服务处理并写入数据库。
- Web App → 提交订单请求
- API Gateway → 路由、鉴权、限流
- Order Service → 校验订单并发送消息到 MQ
- MQ → 存储订单消息
- Order Processor Service → 消费消息并写入数据库
- Orders DB → 持久化订单数据
- Dead-Letter Queue → 收集失败的消息
- Notification Service → 给用户发送通知
这里的关键是 Order Service 要是幂等的。怎么使Order Service幂等?
Order Service从 MQ 消费消息并执行实际的业务逻辑。当处理消息(即订单事件)时,我们希望:
- 检查它是否已被处理
- 如果没有,将其插入Orders DB,告知Notification Service发送通知
这是幂等的,因为已经检查它是否已被处理。可以通过在订单表中执行SELECT操作,仅当不存在时才插入。可以对通知服务执行类似操作,或在通知服务内部使用其自己的数据库执行。
注意需要处理并发问题(有两个不同的订单处理服务实例处理同一条消息,并且它们同时执行SELECT),处理消息两次不是一个合理的做法,更好的方案是将此逻辑包装到事务中。