项目代码里类似有这样的业务逻辑
... ...
Service service1 =serviceDao.getService(serviceId);
if(service1.getStatus == 200){
throw new BusinessException("status exception!");
}
Service service2 =new Service();
service2.setUserId(500);
service2.setStatus(200);
serviceDao.addService(Service2);
... ...
插入service2前先校验之前最新插入的记录service1,如果service1的状态不对,则抛出异常,否则向mysql里插入service2.
很普通的业务逻辑,表面上看起来也没有问题,但随随便便一个并发的重复请求,比如在4毫秒内给这个接口发了三次请求,就能直接击穿了service的状态校验,向mysql里连续插三条记录,很明显违背了业务逻辑,产生了脏数据。这就是接口设计的幂等问题。
有人可能觉得这是DB事务的问题,其实不是,这个并不是幻读,虽然看起来很像。读的定义是事务A读取与搜索条件相匹配的若干行。事务B以插入或删除行等方式来修改事务A的结果集,然后再提交。在我测试环境上,从sql发过去到mysql执行查询再加上网络IO时间大概要10-50ms不等,上面我举的例子是在4ms内发送三个请求进来,阻塞在serviceDao.getService(serviceId);这一步,显然事务BC还没有执行serviceDao.addService(Service2),因此算不上幻读,就算那三个请求发送的间隔时间长一些,真的产生了幻读,本质上也是由于这个接口是非幂等的原因导致的,不要把锅强行甩到mysql的事务隔离等级上。
所谓幂等,即一次请求与多次请求产生的副作用是相同的。比如SELECT、DELETE就是天然的幂等逻辑,因为你无论怎么SELECT,都不会影响db里的数据状态,同理,无论你怎么DELETE,虽说对DB有一些影响,不过仍然不影响其数据状态,顶多是删除不成功。DELETE可以算是一种伪幂等,而INSERT、UPDATE就不同了,INSERT自不必说,至于UPDATE,大家可能觉得反正都是一样的请求,就算update一百次,最终的数据也是一致的,从结果上看确实如此,但从过程上看,会产生类似CAS的重试问题,也就是说,虽然你修改成功了,但我并不知道你修改了多少次,解决方法可以借鉴CAS的版本号,打印日志流水即可。
另外,接口的幂等性和并发没什么太大关系,如果你的接口不是幂等的,并发请求会使这个问题更加严重。就算你的系统和接口没什么并发请求,用户不多,也可能会产生这样的问题,导致DB里的脏数据,而且并不好排查。至于解决方案,大概有这样几种:
1.把锅甩到mysql头上,把事务隔离等级改成可串行化。(根本不行,严重影响mysql读写性能,而且只有这一个接口有幂等问题,改事务隔离等级相当于把所有接口全变成串行了)
2.业务代码加锁。(看起来似乎可以,但是严重降低了这个接口的并发,并不可取)
3.维护一个全局缓存,每次请求都带着uuid,去缓存里做比对,有说明执行过了,没有说明可以执行。(很经典的做法,但增加了复杂度,需要维护缓存淘汰、过期时间、缓存的时机等等)
4.用redis维护分布式锁,同时结合uuid,根据setnx的结果判断是否执行。
5.DB里维护一个去重表,使用生成的唯一主插入,每次进入业务逻辑前先插入去重表,成功即可继续,发生主键冲突即失败(本质上和uuid缓存差不多)
6.重新设计业务逻辑,将非幂等的业务改为幂等(有的没法改),或容忍暂时的数据不一致,通过读写时的其他办法(重新统计等等)实现最终的数据一致。(不适用于rpc调用失败重试的情况,仅适用于对数据一致性要求不高的场景)
一个基于redis分布式锁的简单实现:
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 默认仅打开并发重复请求校验
*/
boolean openConcurrentRepeatValid() default true;
/**
* 并发重复调用情况下接口锁等待时间
*/
long timeout() default 1000;
/**
* 是否打开重复请求校验
*/
boolean openRepeatValid() default false;
/**
* 接口调用成功后禁止重复请求调用的限制时间
*/
long expireTime() default 30000;
}
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private JedisPool jedisPool;
@Pointcut("(execution(* com.hc.*.*.controller.*.*(..))) && " +
"@annotation(org.springframework.web.bind.annotation.RequestMapping) && " +
"@annotation(com.hc.common.idempotent.Idempotent)")
public void pointCut() {
}
@Around("pointCut()")
public Object doFilter(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Idempotent annotation = method.getAnnotation(Idempotent.class);
Class<?> returnType = method.getReturnType();
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
//创建request唯一ID
String requestId = createRequestID(request);
if (requestId == null) {
return joinPoint.proceed();
}
//根据加锁结果判断请求是否重复
if (isRepeatRequest(annotation, requestId)) {
return returnType.newInstance();
}
log.info("cost time:{}", System.currentTimeMillis() - start);
Object proceed = joinPoint.proceed();
//根据调用结果决定是否释放锁
resultHandler(requestId, proceed);
return proceed;
}
private boolean isRepeatRequest(Idempotent idempotent, String requestId) {
long timeout = idempotent.timeout();
long expireTime = idempotent.expireTime();
try (Jedis jedis = jedisPool.getResource()) {
if (idempotent.openRepeatValid()) {
return !"OK".equals(jedis.set(requestId, requestId, "NX", "PX", expireTime));
}
if (idempotent.openConcurrentRepeatValid()) {
return !"OK".equals(jedis.set(requestId, requestId, "NX", "PX", timeout));
}
return false;
} catch (Exception e) {
return false;
}
}
private String createRequestID(HttpServletRequest request) {
String requestURI = request.getRequestURI();
String requestMethod = request.getMethod();
String param = getRequestParam(request);
String ipAddress = Tools.getIpAddress(request);
log.info("filter idempotent request,uri:{},method:{},param:{},ip:{}", requestURI, requestMethod, param, ipAddress);
String bytes;
try {
bytes = DigestUtils.md5DigestAsHex((requestMethod + requestURI + ipAddress + param).getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
return null;
}
return bytes;
}
private String getRequestParam(HttpServletRequest request) {
String contentType = request.getContentType();
String param;
if ("application/json".equals(contentType) ||
"text/xml".equals(contentType)) {
param = parseFromBody(request);
} else if ("application/x-www-form-urlencoded".equals(contentType) ||
"multipart/form-data".equals(contentType)) {
param = parseParamMap(request);
} else {
//TODO pathVariable处理
param = parseParamMap(request);
}
return param;
}
private String parseParamMap(HttpServletRequest request) {
Map<String, String[]> parameterMap = request.getParameterMap();
return convertMap2String(parameterMap);
}
private String convertMap2String(Map<String, String[]> parameterMap) {
StringBuilder stringBuilder = new StringBuilder();
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
stringBuilder.append(entry.getKey());
for (String param : entry.getValue()) {
stringBuilder.append(param);
}
}
return stringBuilder.toString();
}
private void resultHandler(String requestId, Object result) {
try (Jedis jedis = jedisPool.getResource()) {
UniteResponseEntry responseEntry = new UniteResponseEntry();
BeanUtils.copyProperties(result, responseEntry);
if (responseEntry.getCode() != 1) {
//请求失败则可以继续
jedis.del(requestId);
}
} catch (Exception e) {
log.warn("Verify interface idempotent failure");
}
}
private String parseFromBody(HttpServletRequest req) {
try (BufferedReader bufferReaderBody = new BufferedReader(req.getReader())) {
return bufferReaderBody.readLine();
} catch (IOException e) {
return "";
}
}
}