接口幂等性问题处理
理论
幂等性概念
一个幂等操作的特点是任意执行多少次与执行一次产生的影响都是一样的。
幂等函数
或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“getUsername()和setTrue()”函数就是一个幂等函数. 更复杂的操作幂等保证是利用唯一交易号(流水号)实现。
幂等性场景
-
1、查询操作:查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作;
-
2、删除操作:删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个) ;
-
3、唯一索引:防止新增脏数据。比如:支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,所以一个用户新增成功一个资金账户记录。要点:唯一索引或唯一组合索引来防止新增数据存在脏数据(当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可);
-
4、token机制:防止页面重复提交。
原理上通过session token来实现的(也可以通过redis来实现)。当客户端请求页面时,服务器会生成一个随机数Token,并且将Token放置到session当中,然后将Token发给客户端(一般通过构造hidden表单)。 下次客户端提交请求时,Token会随着表单一起提交到服务器端。
服务器端第一次验证相同过后,会将session中的Token值更新下,若用户重复提交,第二次的验证判断将失败,因为用户提交的表单中的Token没变,但服务器端session中Token已经改变了。 -
5、悲观锁 获取数据的时候加锁获取。select * from table_xxx where id=‘xxx’ for update; 注意:id字段一定是主键或者唯一索引,不然是锁表,会死人的;悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用;
-
6、乐观锁——乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。乐观锁的实现方式多种多样可以通过version或者其他状态条件:
a、通过版本号实现update tablexxx set name=#name#,version=version+1 where version=#version#如下图(来自网上);
b、通过条件限制 update tablexxx set avaiamount=avaiamount-#subAmount# where avai_amount-#subAmount# >= 0要求:quality-#subQuality# >= ,这个情景适合不用版本号,只更新是做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高; -
7、分布式锁
如果是分布是系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,这样其实是把多线程并发的锁的思路,引入多多个系统,也就是分布式系统中得解决思路。要点:某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户ID+后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁要第三方系统提供); -
8、select + insert 并发不高的后台系统,或者一些任务JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。注意:核心高并发流程不要用这种方法;
-
9、状态机幂等 在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机(状态变更图),就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。注意:订单等单据类业务,存在很长的状态流转,一定要深刻理解状态机,对业务系统设计能力提高有很大帮助
-
10、对外提供接口的api如何保证幂等
如银联提供的付款接口:需要接入商户提交付款请求时附带:source来源,seq序列号;source+seq在数据库里面做唯一索引,防止多次付款(并发时,只能处理一个请求) 。 重点:对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源source,一个是来源方序列号seq,这个两个字段在提供方系统里面做联合唯一索引,这样当第三方调用时,先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。注意,为了幂等友好,一定要先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,但实际已经处理了
实践
talk is cheap, show you the code
1、redis加锁代码实现
// Copyright 2016-2101 Pica.
package com.pica.cloud.commercialization.crrs.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @ClassName RedisLock
* @Description
* @Author Chongwen.jiang
* @Date 2019/9/10 9:44
* @ModifyDate 2019/9/10 9:44
* @Version 1.0
*/
@Slf4j
@Component
public class RedisLock {
@Autowired
private RedisTemplate redisTemplate;
/**
* @Description 添加元素
* @Author Chongwen.jiang
* @Date 2019/9/10 9:47
* @ModifyDate 2019/9/10 9:47
* @Params [key, value]
* @Return void
*/
public void set(Object key, Object value) {
if (key == null || value == null) {
return;
}
redisTemplate.opsForValue().set(key, value.toString());
}
/**
* @Description 获取数据
* @Author Chongwen.jiang
* @Date 2019/9/10 9:49
* @ModifyDate 2019/9/10 9:49
* @Params [key]
* @Return java.lang.Object
*/
public Object get(Object key) {
if (key == null) {
return null;
}
return redisTemplate.opsForValue().get(key);
}
/**
* @Description 删除
* @Author Chongwen.jiang
* @Date 2019/9/10 9:49
* @ModifyDate 2019/9/10 9:49
* @Params [key]
* @Return java.lang.Boolean
*/
private Boolean remove(Object key) {
if (key == null) {
return false;
}
return redisTemplate.delete(key);
}
/**
* @Description 如果已经存在返回false,否则返回true
* @Author Chongwen.jiang
* @Date 2019/9/10 9:48
* @ModifyDate 2019/9/10 9:48
* @Params [key, value, expireTime, mimeUnit]
* @Return java.lang.Boolean
*/
private Boolean setNx(Object key, Object value, Long expireTime, TimeUnit mimeUnit) {
if (key == null || value == null) {
return false;
}
return redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, mimeUnit);
}
/**
* @Description 加锁
* @Author Chongwen.jiang
* @Date 2019/9/10 9:50
* @ModifyDate 2019/9/10 9:50
* @Params [key(缓存key), waitTime(等待时间,毫秒), expireTime(key过期时间,毫秒)]
* @Return java.lang.Boolean
*/
public Boolean lock(String key, Long waitTime, Long expireTime) {
String value = UUID.randomUUID().toString().replaceAll("-", "").toLowerCase();
Boolean flag = setNx(key, value, expireTime, TimeUnit.MILLISECONDS);
// 尝试获取锁 成功返回
if (flag) {
return flag;
} else {
// 获取失败
// 现在时间
long newTime = System.currentTimeMillis();
// 等待过期时间
long loseTime = newTime + waitTime;
// 不断尝试获取锁成功返回
while (System.currentTimeMillis() < loseTime) {
Boolean testFlag = setNx(key, value, expireTime, TimeUnit.MILLISECONDS);
if (testFlag) {
return testFlag;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
return false;
}
/**
* @Description 释放锁
* @Author Chongwen.jiang
* @Date 2019/9/10 9:54
* @ModifyDate 2019/9/10 9:54
* @Params [key]
* @Return java.lang.Boolean
*/
public Boolean unLock(Object key) {
return remove(key);
}
}
测试一下:
@Autowired
private RedisLock redisLock;
@Test
public void testRedisLock(){
String key = "aaaa";
try {
Boolean locked = redisLock.lock(key, 1000L, 1000*60L);
if(locked) {
System.out.println("获取锁成功,开始执行业务代码...");
Thread.sleep(1000*10);
} else {
System.out.println("获取锁失败...");
}
} catch (Exception e) {
log.error(e.getMessage());
e.printStackTrace();
} finally {
log.info("释放锁:{}", redisLock.unLock(key));
}
}
2、Redisson高性能分布式锁代码实现(AOP实现)
在一些高并发的场景中,比如秒杀,抢票,抢购这些场景,都存在对核心资源,商品库存的争夺,控制不好,库存数量可能被减少到负数,出现超卖的情况,或者 产生唯一的一个递增ID,由于web应用部署在多个机器上,简单的同步加锁是无法实现的,给数据库加锁的话,对于高并发,1000/s的并发,数据库可能由行锁变成表锁,性能下降会厉害。那相对而言,redis的分布式锁,相对而言,是个很好的选择,redis官方推荐使用的Redisson就提供了分布式锁和相关服务。
添加maven依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.5.5</version>
</dependency>
RedissionConfig配置类:
这里使用的是单机连接模式,redisson提供了单机、主从、哨兵、集群等多种连接方式,感兴趣的可以自行从官网了解学习
// Copyright 2016-2101 Pica.
package com.pica.cloud.commercialization.crrs.config;
import io.netty.channel.nio.NioEventLoopGroup;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.Codec;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.ClassUtils;
/**
* @ClassName RedissionConfig
* @Description
* @Author Chongwen.jiang
* @Date 2019/9/6 10:40
* @ModifyDate 2019/9/6 10:40
* @Version 1.0
*/
@Slf4j
@Data
@Configuration
public class RedissonConfig {
private String address = "redis://192.168.110.241:6380";
private int connectionMinimumIdleSize = 10;
private int idleConnectionTimeout = 10000;
private int pingTimeout = 1000;
private int connectTimeout = 10000;
private int timeout = 3000;
private int retryAttempts = 3;
private int retryInterval = 1500;
private int reconnectionTimeout = 3000;
private int failedAttempts = 3;
private String password = null;
private int subscriptionsPerConnection = 5;
private String clientName = null;
private int subscriptionConnectionMinimumIdleSize = 1;
private int subscriptionConnectionPoolSize = 50;
private int connectionPoolSize = 64;
private int database = 0;
private boolean dnsMonitoring = false;
private int dnsMonitoringInterval = 5000;
/**
* 当前处理核数量 * 2
*/
private int thread = 2;
/** redisson默认使用的序列化编码类 */
//private String codec = "org.redisson.codec.JsonJacksonCodec";
private String codec = "org.redisson.codec.FstCodec";
/**
* @Description redisson单机连接模式
* @Author Chongwen.jiang
* @Date 2019/9/10 12:53
* @ModifyDate 2019/9/10 12:53
* @Params []
* @Return org.redisson.api.RedissonClient
*/
@Bean(destroyMethod = "shutdown")
RedissonClient redisson() throws Exception {
log.info("redission init......");
Config config = new Config();
config.useSingleServer().setAddress(address)
.setConnectionMinimumIdleSize(connectionMinimumIdleSize)
.setConnectionPoolSize(connectionPoolSize)
.setDatabase(database)
.setDnsMonitoring(dnsMonitoring)
.setDnsMonitoringInterval(dnsMonitoringInterval)
.setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
.setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
.setSubscriptionsPerConnection(subscriptionsPerConnection)
.setClientName(clientName)
.setFailedAttempts(failedAttempts)
.setRetryAttempts(retryAttempts)
.setRetryInterval(retryInterval)
.setReconnectionTimeout(reconnectionTimeout)
.setTimeout(timeout)
.setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout)
.setPingTimeout(pingTimeout)
.setPassword(password);
Codec codec = (Codec) ClassUtils.forName(getCodec(),
ClassUtils.getDefaultClassLoader()).newInstance();
config.setCodec(codec);
config.setThreads(thread);
config.setEventLoopGroup(new NioEventLoopGroup());
config.setUseLinuxNativeEpoll(false);
return Redisson.create(config);
}
}
这里值得注意的是redisson使用的序列化编码类是org.redisson.codec.JsonJacksonCodec,JsonJackson在序列化有双向引用的对象时,会出现无限循环异常。而fastjson在检查出双向引用后会自动用引用符$ref替换,终止循环。
如果序列化了一个service,这个service已被spring托管,而且和另一个service之间也相互注入了,用fastjson能 正常序列化到redis,而JsonJackson则抛出无限循环异常。
为了序列化后的内容可见,所以使用FstCodec(org.redisson.codec.FstCodec)
定义注解类
package com.pica.cloud.commercialization.crrs.interceptor;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* @ClassName RLock
* @Description 分布式锁
* @Author Chongwen.jiang
* @Date 2019/9/6 10:47
* @ModifyDate 2019/9/6 10:47
* @Version 1.0
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Rlock {
/** 分布式锁的key */
String localKey();
/** 锁释放时间 默认5秒 */
long leaseTime() default 5 * 1000;
/** 时间格式 默认:毫秒 */
TimeUnit timeUtil() default TimeUnit.MILLISECONDS;
}
注解业务实现类:
// Copyright 2016-2101 Pica.
package com.pica.cloud.commercialization.crrs.interceptor;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @ClassName RlockAspect
* @Description 分布式锁aop配置
* @Author Chongwen.jiang
* @Date 2019/9/6 10:52
* @ModifyDate 2019/9/6 10:52
* @Version 1.0
*/
@Slf4j
@Aspect
@Component
public class RlockAspect {
@Autowired
private RedissonClient redissonClient;
ThreadLocal<HttpServletRequest> request = new ThreadLocal();
@Pointcut("@annotation(com.pica.cloud.commercialization.crrs.interceptor.Rlock)")
public void RlockAspect() {
log.info("RlockAspect constructor...");
}
@Around("RlockAspect()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
String params = getAllRequestParam(proceedingJoinPoint);
Object object = null;
RLock lock = null;
try {
Rlock rlockInfo = getRlockInfo(proceedingJoinPoint);
lock = redissonClient.getLock(getLocalKey(proceedingJoinPoint, rlockInfo, params));
if (null != lock) {
final boolean status = lock.tryLock(rlockInfo.leaseTime(), rlockInfo.timeUtil());
if (status) {
log.info("获取到锁.....");
object = proceedingJoinPoint.proceed();
}
}
} finally {
if (lock != null) {
log.info("释放锁.....");
lock.unlock();
}
}
return object;
}
public Rlock getRlockInfo(ProceedingJoinPoint proceedingJoinPoint) {
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
return methodSignature.getMethod().getAnnotation(Rlock.class);
}
/**
* @Description 生成redis lock key
* @Author Chongwen.jiang
* @Date 2019/9/6 11:05
* @ModifyDate 2019/9/6 11:05
* @Params [proceedingJoinPoint, rlockInfo]
* @Return java.lang.String
*/
public String getLocalKey(ProceedingJoinPoint proceedingJoinPoint, Rlock rlockInfo, String params) {
StringBuffer localKey = new StringBuffer("Rlock");
//body中的参数
final Object[] args = proceedingJoinPoint.getArgs();
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
//方法名称
String methodName = methodSignature.getMethod().getName();
//参数加密
//params = jdkAES(params);
localKey.append(rlockInfo.localKey()).append("-")
.append(methodName).append("-")
.append(params);
log.info("getLocalKey: {}", localKey.toString());
return localKey.toString();
}
/**
* @Description 获取query、body和header中的所有参数
* @Author Chongwen.jiang
* @Date 2019/9/6 13:11
* @ModifyDate 2019/9/6 13:11
* @Params [request]
* @Return java.util.Map<java.lang.String,java.lang.String>
*/
private String getAllRequestParam(ProceedingJoinPoint proceedingJoinPoint) {
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes) ra;
HttpServletRequest request = sra.getRequest();
this.request.set(request);
//参数获取
Map<String, Object> params = new ConcurrentHashMap<>();
params.put("url", request.getRequestURL());
params.put("method", request.getMethod());
Object[] bodyParams = proceedingJoinPoint.getArgs();
if (null != bodyParams && bodyParams.length > 0) {
params.put("bodyParams", bodyParams);
}
String queryParams = request.getQueryString();
if (StringUtils.isNotEmpty(queryParams)) {
params.put("queryParams", queryParams);
}
Map<String, String> headerParams = new ConcurrentHashMap<>();
Enumeration<?> headers = request.getHeaderNames();
if (null != headers) {
while (headers.hasMoreElements()) {
String element = (String) headers.nextElement();
String value = request.getHeader(element);
if ("token".equals(element)) {
headerParams.put(element, value);
}
if (StringUtils.isEmpty(element)) {
headerParams.remove(element);
}
}
}
if (!CollectionUtils.isEmpty(headerParams)) {
params.put("headerParams", headerParams);
}
String json = JSON.toJSONString(params);
log.info("getAllRequestParam: {}", json);
return json;
}
/**
* @Description 使用jdk实现AES加密
* @Author Chongwen.jiang
* @Date 2019/9/6 13:48
* @ModifyDate 2019/9/6 13:48
* @Params [source]
* @Return void
*/
private String jdkAES(String source) {
try {
// 生成KEY
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(128);
// 产生密钥
SecretKey secretKey = keyGenerator.generateKey();
// 获取密钥
byte[] keyBytes = secretKey.getEncoded();
// KEY转换
Key key = new SecretKeySpec(keyBytes, "AES");
// 加密
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] result = cipher.doFinal(source.getBytes());
return Hex.encodeHexString(result);
/*System.out.println("jdk aes encrypt:" + Hex.encodeHexString(result));
// 解密
cipher.init(Cipher.DECRYPT_MODE, key);
result = cipher.doFinal(result);
System.out.println("jdk aes decrypt:" + new String(result));*/
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
controller层使用注解
@PostMapping("/{id}")
@Rlock(localKey = "redisLockAnnotion", leaseTime = 10, timeUtil = TimeUnit.SECONDS)
public ApiResponse<?> order(@RequestParam(required = false) String a, @PathVariable String id) {
Map<String, Object> resultMap = dictService.doModify();
return new ApiResponse<>(resultMap);
}
这里我设置的锁释放时间是10秒,所以就需要程序员自己估计业务代码执行时间一定不能超过该时间,否则在释放锁的时候,redisson会抛出异常