提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
幂等设计作为网络通信重要的一环,在消息队列的使用中、在接口调用的场景中,我们或多或少的都要对自己提供的接口进行幂等设计。当我们的接口涉及到金额相关的操作。我们一定要考虑幂等,不然就可能存在。退款多退、订单多下、佣金多发等重大生产事故。
提示:以下是本篇文章正文内容,下面案例可供参考
一、幂等是什么?
幂等其实是数学概念,它的定义是这样的: 如果一个函数 f(x) 满足:f(f(x)) = f(x),则函数 f(x) 满足幂等性。
回到我们的软件设计的过程中: 例如:我们提供出去的接口:被调用一次和调用10次,调用100次,对业务执行的结果都是一样的。 比如: 我们设计这样的接口给达到要去的销售人员,发送佣金。 假如每人发送100元,如果接口支持幂等,那么调用方调用该接口,无论调用多少次。销售人员的账户都只会发送100元的佣金,但是如果不支持幂等,那么就有可能造成销售人员的账户发放100*N(N指调用次数)的佣金。
二、幂等设计的场景
1.多次调用对业务有影响的接口
理论上针对多次调用对业务有影响的接口都需要支持幂等。 那如何去判断,上述例子我们描述了一个需要幂等的场景。 接下里我们介绍一种不需要幂等的场景: 假如我们要去删除一个订单数据接口:这种场景就不需要考虑幂等。 因为对于查询、删除这样的操作是天然的幂等的操作。 对于更新和插入操作,我们就需要考虑幂等,我们是否会更新多次数据或者插入多条数据。
2.消费消息队列中间件的接口
我们现在常用的绝大部分消息队列提供的服务质量都是 At least once(至少一次,可能会推送多次),包括 RocketMQ、RabbitMQ 和 Kafka 都是这样。也就是说,消息队列并不会保证消息不超不重复。 所以我们在设计这样的接口的时候一定要支持幂等,不然就可能造成业务影响。
三、如何幂等设计
接下来我总结了几种幂等设计的思路与方案
1、利用数据库唯一约束
针对插入数据的场景,我们就可以数据库的唯一约束来。例如为我们业务操作生成一个业务主键、当第一次请求的时候,我们保存的数据库保存成功、当第二次请求用同一个主键、保存数据就会失败。
这里不仅仅是可以用我们传统的关系型数据库Mysql/Oracle等,在非关系型数据库比如redis也同样适用。比如SetNx 但是SetNx需要考率缓存过期的问题,比如sentNx的缓存为10分钟,但是我们两次请求间隔超过10分钟。 Redis锁的实现代码
@Component
public class RedisLock {
private static final Long RELEASE_SUCCESS = 1L;
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "EX";
private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
@Autowired
private StringRedisTemplate redisTemplate;
public boolean tryLock(String lockKey, String clientId, long seconds) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Object nativeConnection = redisConnection.getNativeConnection();
RedisSerializer<String> stringRedisSerializer = (RedisSerializer<String>) redisTemplate.getKeySerializer();
byte[] keyByte = stringRedisSerializer.serialize(lockKey);
byte[] valueByte = stringRedisSerializer.serialize(clientId);
if (nativeConnection instanceof RedisAsyncCommands) {
RedisAsyncCommands connection = (RedisAsyncCommands) nativeConnection;
RedisCommands commands = connection.getStatefulConnection().sync();
String result = commands.set(keyByte, valueByte, SetArgs.Builder.nx().ex(seconds));
if (LOCK_SUCCESS.equals(result)) {
return true;
}
}
if (nativeConnection instanceof RedisAdvancedClusterAsyncCommands) {
RedisAdvancedClusterAsyncCommands connection = (RedisAdvancedClusterAsyncCommands) nativeConnection;
RedisAdvancedClusterCommands commands = connection.getStatefulConnection().sync();
String result = commands.set(keyByte, valueByte, SetArgs.Builder.nx().ex(seconds));
if (LOCK_SUCCESS.equals(result)) {
return true;
}
}
if (nativeConnection instanceof JedisCommands) {
JedisCommands jedis = (JedisCommands) nativeConnection;
String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
}
return false;
});
}
public boolean releaseLock(String lockKey, String clientId) {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(RELEASE_LOCK_SCRIPT);
redisScript.setResultType(Integer.class);
Object execute = redisTemplate.execute((RedisConnection connection) -> connection.eval(
RELEASE_LOCK_SCRIPT.getBytes(),
ReturnType.INTEGER,
1,
lockKey.getBytes(),
clientId.getBytes()));
if (RELEASE_SUCCESS.equals(execute)) {
return true;
}
return false;
}
}
2、Http的幂等
- GET:只是获取资源,对资源本身没有任何副作用,天然的幂等性。 HEAD:本质上和GET一样,获取头信息,主要是探活的作用,具有幂等性。
- OPTIONS:获取当前URL所支持的方法,因此也是具有幂等性的。
- DELETE:用于删除资源,有副作用,但是它应该满足幂等性,比如根据id删除某一个资源,调用方可以调用N次而不用担心引起的错误(根据业务需求而变)。
- PUT:用于更新资源,有副作用,但是它应该满足幂等性,比如根据id更新数据,调用多次和N次的作用是相同的(根据业务需求而变)。
- POST:用于添加资源,多次提交很可能产生副作用,比如订单提交,多次提交很可能产生多笔订单。
3、为更新的数据设置前置条件(乐观锁)
当我们更新数据的时候。我们可以使用乐观锁的思想: 例如我们有这样的一种需求,我们需要给账户A转100块钱,这种场景,我们显然是需要支持幂等。我们正常调用多次,可能就会给A转多的钱。当我们转变思想,我们在更新前查询出A账户目前的余额,假设是200.所以我们的的更新语句就可以是:update XX set account = account +100 where account =200 ,这样就支持了幂等,只有在账户余额为200的情况下,我们才能更新成功、那么这样操作手法只能是数值的情况,其实我们变通下即使不是数据,我们也可以用vesion 版本号来替代上面的账户余额。这样什么样的数据都可以通用了。
4、记录并检查操作 (Token)
还有一种通用的手法就是Token机制:
主要的流程步骤如下:
- 客户端先发送获取token的请求,服务端会生成一个全局唯一的ID保存在redis中,同时把这个ID返回给客户端。
- 客户端调用业务请求的时候必须携带这个token,一般放在请求头上。
- 服务端会校验这个Token,如果校验成功,则执行业务。
- 如果校验失败,则表示重复操作,直接返回指定的结果给客户端。
通过以上的流程分析,唯一的重点就是这个全局唯一ID如何生成,在分布式服务中往往都会有一个生成全局ID的服务来保证ID的唯一性,但是工程量和实现难度比较大,UUID的数据量相对有些大,就可以选择facebook的雪花算法、美团的leaf算法。注意雪花算法存在时间倒拨的问题。
5、状态机算法
- 很多业务中多有多个状态,比如订单的状态有提交、待支付、已支付、取消、退款等等状态。后端可以根据不同的状态去保证幂等性,比如在退款的时候,一定要保证这笔订单是已支付的状态。
- 业务中常常出现,有兴趣的同学可以自行去了解下,后续我在补充具体的逻辑
总结
- 针对数据插入场景:可以选择使用数据库唯一约束的方案
- 针对数据更新的场景:可以选择为更新数据设置前置条件(数据库乐观锁 Vesion等)
- 针对Http请求,可以使用PUT代替POST达到幂等的效果。
- 比较通用的方案:可以选择使用 TOKEN机制、状态机模式
参考文章:
https://blog.csdn.net/qq_34162294/article/details/105252181 https://time.geekbang.org/column/article/111552
|