优质博文:IT-BLOG-CN
幂等 操作的特点是一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。这对于保证系统的一致性和可靠性非常重要。
具体来说,当一个接口被设计为幂等的时候,无论请求被执行多少次,结果都是一样的。这样可以避免由于网络延迟、重试或其他原因导致的重复请求对系统造成的副作用,比如重复创建订单、重复扣款等。实现接口幂等性可以提高系统的可靠性和稳定性,减少不必要的资源消耗和数据错误。同时,对于一些需要保证数据一致性的操作,比如金融交易、库存管理等,实现接口幂等性也是非常重要的。
一、为什么要实现接口幂等性
【1】前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
【2】接口超时重复提交: 很多时候HTTP
客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
【3】消息进行重复消费: 当使用MQ
消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
【4】用户恶意进行刷单: 在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
二、什么时候做幂等性
请求类型 | 是否需要幂等 | 描述 |
---|---|---|
Get | 自身满足幂等性 | Get 方法用于获取资源。其一般不会也不应当对系统资源进行改变,所以是幂等的。 |
Post | 自身不满足幂等性 | Post 方法一般用于创建新的资源。其每次执行都会新增数据,所以不是幂等的。 |
Put | 可能满足,可能不满足 | Put 方法一般用于修改资源。该操作则分情况来判断是不是满足幂等,更新操作中直接根据某个值进行更新,也能保持幂等。不过执行累加操作的更新是非幂等。 |
Delete | 可能满足,可能不满足 | Delete 方法一般用于删除资源。该操作则分情况来判断是不是满足幂等,当根据唯一值进行删除时,删除同一个数据多次执行效果一样。但带查询条件的删除就不一定满足幂等,例如在根据某条件删除一批数据后,这时候新增加了一条数据也满足条件,然后又执行了一次删除,那么将会导致新增加的这条满足条件数据也被删除。 |
三、如何实现幂等性
客户端
客户端防止重复提交并不是绝对可靠的,可以通过工具略过前端直接访问后端。优点是实现起来比较简单。
按钮只能操作一次
提交后把按钮置灰或loding
状态,消除用户因为重复点击而产生的重复记录,比如添加操作,由于点击两次而产生两条记录。
使用Post/Redirect/Get模式
在提交后执行页面重定向,这就是所谓的Post-Redirect—Get(PRG)
模式。当用户提交表单后,跳转到一个重定向的信息页面,避免用户按F5刷新导致的重复提交,而且也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退导致同样重复提交的问题。
服务端
数据库唯一主键
利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式ID
充当主键,这样才能能保证在分布式环境下ID
的全局唯一性。
唯一索引
与唯一主键的思想一致,通过唯一性确保插入的幂等性。创建订单时,前端先通过接口获取订单号,再请求后端时带入订单号,订单表中订单号添加唯一索引,如果存在插入相同订单号则直接报错。消费MQ
消息时,messageId
是唯一的,我们可以新添加一种消费记录表,将messageId
作为主键,如果重复消费那么就会存在相同的messageId
,插入直接报错。
乐观锁
乐观锁就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制。数据库乐观锁一般只能适用于执行“更新操作”的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。每次对该数据库数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据的版本标识。
悲观锁
当对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。如下伪代码通过for update
添加悲观锁,这里order_id
需要有索引,否则会锁整张表。悲观锁影响性能一般不建议。
begin; -- 1.开始事务
-- 查询订单,判断状态
select order_id,status from orders where order_id='1000234' for update
if(status != 'S'){
-- 非订单状态,不能更新为已完成;
return ;
}
-- 更新完成
update order set status='s' order_no='1000234'
commit; -- 2.提交事务
状态码
很多业务表,都是有状态的,比如订单表,一般订单有1-订单创建、2-订单支付、3-订单完成、4-取消订单等订单流程,当我们更新订单状态
update orders set status=3 where order_id='1000234' and status=2;
第一次请求时,将“订单支付”状态修改成“订单完成”,sql
执行结果的影响行数是1。
第二次重复请求时,同样将“订单支付”状态修改成“订单完成”,但是sql
执行结果的影响行数为0。如果是0,那么我们直接可以返回成功了。不需要做接下来的业务操作,以此来保证保证接口的幂等性。
基于分布式锁
分布式锁实现幂等性的逻辑就是,请求过来时,先去尝试获得分布式锁,如果获得成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。其实前面介绍过的悲观锁,本质是使用了数据库的分布式锁,都是将多个操作打包成一个原子操作,保证幂等。但由于数据库分布式锁的性能不太好,我们都是通过Redis
或Zookeeper
来实现分布式锁。
客户端 + 服务端
Token机制
针对客户端连续点击或者调用方的超时重试等情况,可以用Token
的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局ID(Token)
,请求的时候携带这个全局ID
一起请求(Token
最好将其放到Headers
中),后端需要对这个Token
作为Key
,用户信息作为Value
到Redis
中进行键值内容校验,如果Key
存在且Value
匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的Key
或Value
不匹配就返回重复执行的错误信息,这样来保证幂等操作。
Token
工具类: 接收Token
串,加上Key
前缀形成Key
,再传入value
值,执行Lua
表达式保证命令执行的原子性,查找对应Key
与删除操作。执行完成后验证命令的返回结果,如果结果不为空且非0,则验证成功,否则失败。
public class TockenUtlls {
@Autowired
private RedisTemplate redisTemplate;
// token前缀
private static final String TOKEN_PRE = "token_";
// 创建 token 并传入 Redis
public String generateToken(String value) {
// 通过 UUID 创建 Token
String token = UUID.randomUUID().toString();
String key = TOKEN_PRE + token;
// 存储 Token 到 Redis 并设置超时时间
redisTemplate.opsForValue().set(key, value, 10, TimeUnit.MINUTES);
return token;
}
// 验证 Token
public boolean validToken(String token, String value) {
// 设置 LUA 脚本, KEYS[1] 代表 key, KEYS[2] 代表 value
String luaScript = " if redis.call('get', KEYS[1]) == KEYS[2]
then return redis.call('get', KEYS[1])
else return 0
end ";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
// 拼接 Redis Key
String key = TOKEN_PRE + token;
// 执行 LUA 脚本
Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
// 根据返回值判断是否匹配成功并删除 Redis 键值对。 结果不为空则表示成功
if (result != null && result != 0L) {
redisTemplate.opsForValue().getAndDelete(key);
return true;
}
return false;
}
}
唯一序号
所谓请求序列号,其实就是每次向服务端请求时候附带一个短时间内唯一不重复的序列号,该序列号可以是一个有序ID
,也可以是一个订单号,一般由上游生成,在调用下游服务端接口时附加该序列号和用于认证的ID
。当下游服务器收到请求信息后拿取该“序列号”和上游"认证ID"进行组合,形成用于操作Redis
的Key
,然后到Redis
中查询是否存在对应的Key
的键值对,如果不存在,就以该Key
作为Redis
的键,以上游关键信息作为存储的值,将该键值对存储到Redis
中 ,然后再正常执行对应的业务逻辑即可。