幂等设计
[TOC]
幂等性,是指同一操作对同一系统的任意多次执行,所产生的影响均与一次执行的影响相同,不会因为多次执行而产生副作用。
在面对系统失败和超时,意外重复,就需要率幂等问题了。尤其是在超时的情况下,并不知道到底是成功了还是失败了,所以幂等设计在服务和接口设计中非常重要。
常见需要考虑幂等的场景:
- 接口重复提交;
- MQ消息重复提交,重复消费;
- 服务超时重试,失败重试。
常见的实现方法有 唯一Token、索引等。它的本质是通过唯一标识,标记同一操作的方式,来消除多次执行的副作用。
当然有些操作天然就有幂等性,比如一些 Get,Delete 操作,执行一次和多次没有区别。
一些幂等操作
接口 GET 请求获取数据
数据库
-
select 查询
- delete from user where userid=1;
- update user set money = 100 where userid=1; 不是所有的 update 都是幂等的
全局唯一ID
用全局的唯一ID,来标示操作的唯一性。
那么问题来了:
- 怎么生成全局唯一ID?
- 全局唯一ID 由谁来生成和分配?
唯一Id 生成如百度的 uid-generator
、美团的 Leaf
,Twitter 的 snowflake
,UUID
等。
如果由一个中心系统来分配,会增加了往返性能开销,也容易产生热点问题。
幂等性服务也是分布式的,所以,需要这个存储也是共享的。这样每个服务就变成没有状态的了。但是,这个存储就成了一个非常关键的依赖,其扩展性和可用性也成了非常关键的指标。
UUID
UUID 这样冲突非常小的算法。但 UUID 的问题是,它的字符串占用的空间比较大,索引的效率非常低,生成的 ID 太过于随机,可读性太差,而且没有递增,如果要按前后顺序排序的话,就不太适合了。
import (
"fmt"
"github.com/google/uuid"
)
func Uid() {
for i := 0; i < 10; i++ {
fmt.Println(uuid.Must(uuid.NewRandom()))
}
}
//71c96c0d-4d17-4765-aba5-4f6cd1189427
//ba055ed9-4c05-452b-beb0-1a35abf492e1
//c8c39e80-3f0a-41f4-9ca5-9f16d67f9ffc
//0449cbcf-a29a-4632-853c-8da3c85a39be
//a5a89ee0-3a44-472d-8d00-ea4b06c12b09
//749faaa1-17b8-4da5-b715-e2ba953949ba
//d14b0de6-03c9-47b8-8788-858f86b6b055
//9e4c295e-6c3a-48d6-b789-f4588262b89f
//c4f5fdce-b9d6-4c26-851d-32601af34c6d
//0d0ce15a-b0a2-433d-9192-8c1bb5ede2ac
随机生成的 uuid 有122个随机位。一个人每年被陨石击中的概率估计为170亿分之一,每年产生几万亿个uuid 才有一个重复的概率,发生重复概微乎其微。
Snowflake
Twitter 的开源项目 Snowflake, 即雪花算法。它是一个分布式 ID 的生成算法。其核心思想是,产生一个64位整型的 ID。
有三部分组成:
- 41bits 作为毫秒数。大概可以用 69.7 年。
- 10bits 作为机器编号(5bits 是数据中心,5bits 的机器 ID),支持 1024 个实例。
- 12bits 作为毫秒内的序列号。一毫秒可以生成 2^12 也就是 4096 个序号。
+--------------------------------------------------------------------------+
| 1 Bit Unused | 41 Bit Timestamp | 10 Bit NodeID | 12 Bit Sequence ID |
+--------------------------------------------------------------------------+
Golang 代码实现 https://github.com/bwmarrin/snowflake
每次生成一个ID时,它都是这样工作的。通过 Epoch 指定纪元时间。
- 使用ID的41位存储毫秒精度的时间戳。
- 然后将NodeID添加到后续位中。
- 然后添加序列号,从0开始,并随着同一毫秒内生成的每个ID而递增。如果同一毫秒内生成了超过 4096 个id,使得序列将滚过或满溢,那么ID 生成器函数将暂停,直到下一毫秒。
通过设置雪花,可以更改节点id和序列号的位数。这两个值之间最多可以共享22位。你不必使用所有的22位。
import (
"fmt"
"github.com/bwmarrin/snowflake"
)
func Sn1(nodeId int64) {
// Create a new Node with a Node number of 1
node, err := snowflake.NewNode(nodeId)
if err != nil {
fmt.Println(err)
return
}
for i := 0; i < 5; i++ {
// Generate a snowflake ID.
id := node.Generate()
// Print out the ID in a few different ways.
fmt.Printf("Int64 ID: %d\n", id)
}
fmt.Println()
}
//Int64 ID: 1358242168543645696
//Int64 ID: 1358242168543645697
//Int64 ID: 1358242168543645698
//Int64 ID: 1358242168543645699
//Int64 ID: 1358242168543645700
优点:
- 按照时间自增,并且根据节点区分分布式系统内不会产生ID碰撞,并且效率较高。
- 实现简单,无第三方依赖。
- 可以根据业务特点灵活调整 bit 分配。
缺点:
- 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复。
具体实现
Token 机制
每次请求生成唯一的 Token 来区分请求。
数据库唯
数据库唯一索引
最简单的方式就是数据库字段的唯一索引。
插入或更新
INSERT INTO … values … ON DUPLICATE KEY UPDATE …
多版本控制
就可以在更新的接口中增加一个版本号,来做幂等。
在操作业务前,需要先查询出当前的 version 版本,再更新,这个利用了乐观锁
的思路。但是乐观锁在面临并发竞争的时候,都要面对失败的情况。
UPDATE goods SET name = ${newName}, version = ${newVersion} WHERE id = S{id} AND version < ${newVersion}
状态机
通过状态流转实现幂等、
如订单的创建和付款,订单的付款肯定是在之前,设计 int 型状态字段时,并通过值类型的大小来做幂等。
订单创建成功为 0,已付款为状态 1,商家已接单为状态20,骑手接单为状态 40,骑手已取货为 60,已签收为 80,订单完成为 100,等等。
在做状态机更新时,我们就这可以这样控制:
UPDATE `order` SET status = S{status} WHERE id = ${id} AND status < s{status}
这个其实也算是版本控制。
Redis 唯一键
基于 SETNX 命令.
SETNX key value, 将 key 的值设为 value ,当且仅当 key 不存在才能设置成功。
HTTP 的幂等性
满足幂等
-
HTTP GET 方法用于获取资源,不应有副作用,所以是幂等的。需要注意的是,GET一次和 N 次具有相同的副作用,而不是每次 GET 的结果相同。
-
HTTP HEAD 和 GET 本质是一样的,区别在于 HEAD 不含有呈现数据,而仅仅是 HTTP 头信息,不应用有副作用,也是幂等的。
-
HTTP OPTIONS 主要用于获取当前 URL 所支持的方法,所以也是幂等的。
- HTTP DELETE 方法用于删除资源,有副作用,但它应该满足幂等性。
- HTTP PUT 方法用于创建或更新操作,所对应的 URI 是要创建或更新的资源本身,有副作用,但满足幂等性。
不满足幂等性
- HTTP POST 方法用于创建资源,所对应的 URI 并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性。
POST 幂等设计
1、在表单中需要隐藏一个 token,这个 token 可以是前端生成的一个唯一的 ID。这个 token 是表单的唯一标识, 用于防止表单重复提交。(这种情况其实是通过前端生成唯一ID 把 POST 变成了 PUT
)。
提交后,后端会把用户提交的数据和这个 token 保存在数据库中
。如果有重复提交,那么数据库中的 token 会做排它限制,从而做到幂等性。
2、Post/Redirect/Get (PRG)模式。
默认情况,提交 Post 请求到服务器后,如果直接刷新浏览器,会重新再提交一次 Post 请求,这样就会产生重复提交问题。
当服务器处理完 Post 请求后,通过重定向,让浏览器用 Get 方式立刻访问另一条 URL,用户浏览器拿到 Get 请求的数据,整个流程才算结束。此时用户刷新当前页面,也不会引起 Post 请求的重复提交了。
此外,Post 请求直接返回的网页收藏到书签是无效的,采用 PRG 方式,用户请求重定向后 GET 方法返回的网页,这样使得收藏有效了。
分布式环境
很多时候,需要在一个分布式环境多个节点之间满足幂等性,这就需要一个公共的存储。这样可以避免单个节点的状态。但是,这个存储就成了一个非常关键的依赖,其扩展性
和可用性
也成了非常关键的指标。
这是可以考虑使用KV数据库,ZAB,Raft,Quorum NWR 算法结合数据分片,来实现强一致和高可用。
也可以直接把唯一ID与数据一起存储。比如交易流水号。
小结
变来变去,其实最核心的就是通过一个唯一表示来区分,请求的唯一性。