LabHub
学习 学习路径 课程

队列与异步 API

「恰好一次」这个神话

在 LabHub 中继续学习

一句话总结

准确地一次=至少一次传递+接收方的重复删除。不要等待基础设施神奇地完成,而是让消费者平等。

概念图: 永远失败的信息 · 重新尝试时间隔设限。 · 超过上限的话,会以死亡信队列的形式发送。 · 请一起留下与原始信息一起失败的原因、尝试了几次、最后错误是什么。

为什么需要这个?

我按了结账按钮,但是屏幕就卡住了,用户重新按了,服务器收到了两次请求,如果两次都处理的话,那就是双重收费。

这一场景的根源是前面看到的无法区分的问题。当发送方没有收到确认回复时,无法知道信息是否没有到达,或者是否只丢失了回复。因此,为了防止丢失,必须重新尝试,而重新尝试会产生重复。

只有两种策略。最多发送一次——发送后忘记,可能丢失,不重复。至少发送一次——直到收到确认回复为止重试,不丢失,可能重复。大多数系统选择后者。因为丢失比重复更糟糕。

怎么行动

首先要区分等号运算和非等号运算。“请将电子邮件设置为a@b.com”无论发送多少次,结果都是一个。如果两次发送“请增加余额100韩元”,结果是200韩元。设置是等号运算,不是增加。

HTTP 在方法层面上已经规定了这一点。GET/HEAD/OPTIONS 是安全的,是平等的,PUT/DELETE 是平等的,不是POST。PUT是平等的原因是“把这个资源变成这个值”的设置运算。DELETE也是如此——即使再删除已经删除的东西,最终状态也是相同的。响应代码可能会变成404,但平等性是关于状态的性质,不是关于响应代码的性质。

使POST均匀化的标准方法是均匀性键。客户端每次请求都会创建一个UUID,上传到头衔中,服务器会记住该键,如果再次出现相同的键,不会重新处理,而是直接返回保存的第一个响应。Stripe和PayPal正是采用这种方式。

在实现中需要注意的地方有四个。

第一,保存和到期的键。不能永久保存,所以通常设置为24小时。

第二,同步性。如果两个请求的键值相同,几乎同时到达,可能会误认为是“第一次看到的键值”,从而进行双重处理。收到键值的瞬间,必须进行原子性抢占。Redis线程SET key <state> NX EX 86400一次就可以了。

第三,验证密钥和正文的一致性。如果密钥相同但正文不同,可能是客户端错误或攻击。最好拒绝。

第四,失败的性质区分。暂时性失败(DB超时)需要真正重新尝试,但确定性失败(余额不足)最好是给出类似的答案。

在现场相遇的样子

在消息队列消费者中,有键已经有的情况很多。是活动ID或集合体ID+序列。将处理过的ID记录为Redis集合或DB唯一约束就结束了。使用唯一约束的话,数据库会代替保证原子性,所以竞争条件消失了。

一个需要注意的陷阱。如果“处理完毕”和“实际处理”在不同的存储库中,就会再次出现双写问题。如果可能的话,请将其放入同一事务中。

失败的信息会去哪里

即使有重试性,也有剩余的问题。永远失败的信息。格式被破坏了,参考的数据被删除了,代码有缺陷,即使重试几次也会出现同样的异常。如果这个消息出现在队列的头,那么后面正常的消息都会被阻挡,重试会不断消耗资源。

所以需要两个设备。

**重新尝试时间隔设限。**如果立即重新尝试,由于同样的原因又失败,所以逐渐增加间隔。而且如果多个消费者同时失败的话,重新尝试的时间也会同时集中,所以在间隔中加入一点随机性,分散开来。如果没有这个,一旦相对服务恢复,就会立即受到重新尝试风暴,再次崩溃。

**超过上限的话,会以死亡信队列的形式发送。**这是在处理流程中删除但不丢弃的位置。搬到这里时,**请一起留下与原始信息一起失败的原因、尝试了几次、最后错误是什么。**如果没有这些信息,以后打开那个队列也无法知道该怎么办。

死去的信队列很容易忘记。**如果不挂上通知,队列的长度就会没有人看到。**而且,在通知响起时,也必须确定要做什么。是否修改代码后重新输入,是否修改数据,是否直接丢弃。这种判断通常是业务负责人的事,所以必须有办法以人们可以阅读的形式显示出什么出了问题。

最后,关于处理顺序的一点。**重试会打破顺序。**因为在前面信息被推迟到重试期间,后面的信息会先被处理。如果顺序是重要的流程,那么应该将相同键的消息打成一行处理,或者为了不完全依赖顺序,每个消息都设计成包含最终状态。后者更结实,这就是前面看到的“设置不是平等的,增长也不是”之类的故事。

下次实习要做的事情

故意制作产生双重请求的结算服务,加上排序键,在同时请求时也只处理一次,加入原子性先决权,拒绝文本不一致,并设置到键过期为止。