数据库保存相同的多条记录,一般从三个方面进行加固
1、端和h5做事件重复触发
缺点:各种机型和浏览器等兼容性问题会导致部分漏网之鱼,还有接口工具可能直接并发绕过常规
2、服务端做防重放处理,本次记录是服务端处理
3、数据库做唯一约束
缺点:相当于重新走了一圈服务的业务逻辑,数据库层面报错体验也不友好,服务端和数据层都增加压力
下面说下服务端的解决方案
1、token
前端+后端调整
前端请求验证后服务端保存token到session或redis里并返回给前端,前端请求时把token带回来由服务端验证,业务流程走完时删除token
2、
后端
用redis,做业务编号和userId存放在redis中,redis中过期时间设置1到2秒释放,同一个业务操作一般重放逼近时间相同,业务流程走完删除key
3、
后端无感方案,有重放时阻塞,等第一个执行完了,第二个去验证第一个执行结果再再返回成功和失败,或者也可以再次执行下,一般情况都是没有新增有就更新的业务逻辑
3.1在拦截器preHandle给redis加锁
3.2在拦截器preHandle判断有锁阻塞,直到获取解锁验证业务是否正常,正常返回成功结果
3.3在拦截器postHandle给redis解锁
java+Lua 原子性,限流
import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
@Slf4j
@Component
public class RedisLua {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
*INCRBY 请求数加1
*/
String script = "local key = KEYS[1]\n" +
"local val = tonumber(ARGV[1])\n" +
"local limit = tonumber(ARGV[2])\n" +
"local ss = tonumber(ARGV[3])\n" +
"local current = tonumber(redis.call(\"get\", key) or \"0\")\n" +
"if current + 1 > limit then\n" +
" return 0\n" +
"else\n" +
" redis.call(\"INCRBY\", key, val)\n" +
" redis.call(\"expire\", key, ss)\n" +
"end\n" +
"return 1";
/**
*
* @param script lua脚本
* @param key key
* @param value value
* @param count 限流次数
* @param expire 超时时间:单位:秒
* @throws Exception
*/
public Object eavl(String script,String key,String value,int count,int expire){
try{
Object ret = stringRedisTemplate.execute(new RedisCallback() {
public Object doInRedis(RedisConnection connection) {
Jedis jedis = (Jedis) connection.getNativeConnection();
return jedis.eval(script, Lists.newArrayList(key),Lists.newArrayList(value,String.valueOf(count),String.valueOf(expire)));
}
}, true);
return ret;
}catch (Exception ex){
log.error("幂等性异常,不做拦截",ex);
}
return null;
}
/**
*
* @param key key
* @param value value
* @param count 限流次数
* @param expire 超时时间:单位:秒
* @throws Exception
*/
public Object eavl(String key,String value,int count,int expire){
return this.eavl(script,key,value,count,expire);
}
}
拦截器做重放处理
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
RedisLua redisLua;
String idempotenPrefix = "idempotent";
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object o) throws Exception {
String mehtod = req.getMethod();
String lastPath = req.getServletPath().substring(req.getServletPath().lastIndexOf("/")+1);//最path中最后一个地址,如果这个没办法排除连续唯一性可以把全路径都用'/'替换成":"做为redis的key
if("post".equals(mehtod.toLowerCase())){//只做post请求
if (!checkIdempotent(user.getUid(),lastPath)){//flowStep一个步骤可能有多个post请求
log.warn("idempoten user {},path {}",userId,req.getServletPath());
log.warn("idempoten expire second:"+redisLua.getExpire(idempotenPrefix+":"+lastPath+":"+userId));
//resp.getWriter().write();这里可以做前端提示或无感重复提交返回成功后的跳转
return false;
}
}
return true;
/**
* 重放,幂等性问题拦截
* @param userId
* @return
*/
private boolean checkIdempotent(Long userId,String step) {
String key = idempotenPrefix+":"+step+":"+userId;
Object ret = redisLua.eavl(key,"1",1,1);
if (null == ret){//redis异常不拦截
return true;
}
if (1 == Integer.valueOf(ret.toString())){//正常
return true;
}
return false;
}
}