大型互联网项目中,很多流量都达到亿级。同一时间很多的人在使用,而每个用户提交表单的时候都可能会出现重复点击的情况,此时如果不做好控制,那么系统将会产生很多的数据重复的问题。怎样去设计一个高可用的防重复提交方案呢?博主将在此为大家详细分享当前自己负责的一个亿级流量项目中如何实现防重复提交。
首先,博主在介绍之前,先介绍下这个亿级项目的故事。博主在16年入驻公司后,进入了该项目组,此时项目面对大流量访问的情况可谓非常糟。客户天天跟公司搞事情,不信任团队。具体问题争论点如下:
1.用户在提交数据后,很多时候总会有重复提交(商城秒抢活动更甚,几乎不可用)
2.系统每天宕机好几次(后经代码+服务器数据分析,a各种流没使用正确的方式去关闭;b.tomcat对应的session redis同步包有io句柄泄露;c.各种代码不规范写法如死循环、直接用new Thread()、StringBuffer乱用等;d.编写的sql性能问题等等)
3.很多功能点不可用,如数据量大的报表无法导出、数据量大的excel无法导入、pc端和微信端用户信息等数据同步问题、大数据量的表单下拉选择无法使用、微信公众号里面图片无法多图片上传、商城秒抢活动经常出现商品超卖、无界线程(newCachedThreadPool)池乱用
4.很多时候发版都出现功能漏发(服务器太多、且每台有多个实例)
..........
总之,问题多得博主数都数不过来.
当然,上面的这些问题都很重要,必须解决;博主此次就单独分享在解决防重复提交这个问题上的方案,其它的问题的解决方案如分布式锁、pc端单端登录(微信端非单端)等等在后期讲解。
首先说一下防重复提交token的工作流程:
用户访问表单添加页面->spring防重复token拦截器拦截请求url,判断url对应的controller方法是是否注解有生成防重复token的标识->生成防重复token保存到redis中RedisUtil.getRu().setex("formToken_" + uuid, "1", 60 * 60);同时将本次生存的防重复token放到session中->跳转到表单页面时重token中取出放入表单->用户填写完信息提交表单->spring防重复token拦截器拦截请求url,判断提交的url对应controller方法是否注解有处理防重复提交token的标识->redis中formToken做原子减1操作RedisUtil.getRu().decr("formToken_" + clinetToken);如果不redis中做了减1操作后值不为0,则为重复提交,返回false。
防重复提交token生成流程图:
防重复表单提交处理流程图:
代码设计部分(此处由于非ajax提交表单时只需要两个注解就可以实现,故此处不作详细解读):
FormToken类,表单类型注解
package com.empire.form;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 类FormToken.java的实现描述:FormToken注解
*
* @author arron 2017年3月14日 下午8:51:20
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FormToken {
/**
* 需要防重复功能的表单入口URL对应的controller方法需要添加的注解,用于生成token(默认为uuid)
* @return
*/
boolean save() default false;
/**
* 防重复表单提交表单到后台对应的URL的controller方法需要添加的注解,用于第一次成功提交后remove掉token
* @return
*/
boolean remove() default false;
/**
* 是否让token防重复拦截器放过token校验,为true时一般用于ajax提交放过到controller中处理,此功能可以在提交失败后恢复token
* @return
*/
boolean pass() default false;//如果拦截到位表单重复提交是否放过让controller处理,默认被拦截器处理返回false;
}
TokenInterceptor(自定义token拦截器)
package com.empire.interceptor;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.log4j.Logger;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import com.empire.utils.RedisUtil;
/**
* 类TokenInterceptor.java的实现描述:拦截器:防止重复提交
*
* @author arron 2017年7月23日 下午5:14:32
*/
public class TokenInterceptor extends HandlerInterceptorAdapter {
private static final Logger logger = Logger.getLogger(FormToken.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
FormToken annotation = method.getAnnotation(FormToken.class);
if (annotation != null) {
boolean needSaveSession = annotation.save();
if (needSaveSession) {
Date d = new Date();
String uuid = UUID.randomUUID().toString();
RedisUtil.getRu().setex("formToken_" + uuid, "1", 60 * 60);
request.getSession(true).setAttribute("formToken", uuid);
logger.warn(request.getServletPath() + "---->formToken:" + uuid);
}
boolean needRemoveSession = annotation.remove();
if (needRemoveSession) {
if (isRepeatSubmit(request)) {
logger.warn("please don't repeat submit,url:" + request.getServletPath());
boolean pass = annotation.pass();
request.setAttribute("formToken_pass_repeat", "true");
return pass;
}
//request.getSession(true).removeAttribute("token");
}
}
return true;
} else {
return super.preHandle(request, response, handler);
}
}
private boolean isRepeatSubmit(HttpServletRequest request) {
String clinetToken = request.getParameter("formToken");
if (clinetToken == null) {
return true;
}
boolean r = RedisUtil.getRu().exists("formToken_" + clinetToken);
if (r) {
Long upCount = RedisUtil.getRu().decr("formToken_" + clinetToken);
//如果对更新标记做减减操作后不等0,表示是重复提交
if (upCount != 0) {
RedisUtil.getRu().del("formToken_" + clinetToken);
return true;
} else {
RedisUtil.getRu().del("formToken_" + clinetToken);
return false;
}
} else {
return true;
}
}
}
controller表单入口方法:
@FormToken(save = true)
@RequestMapping(value = "addAppointPage", produces = "text/html")
public String addAppointPage(HttpServletRequest request, HttpServletResponse response, Model uiModel) {
//业务代码
......
return "pcpageAppoint/add";
}
jsp(例子为ajax方式情况)
<jsp:directive.page contentType="text/html;charset=UTF-8"/>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
//script、css import
.......
<script>
function addAppoint(){
var formToken = $("#formToken").val();
var flag=true;
var mileage=$('#mileage').val();
if(mileage==""){
alert("请输入行驶里程");
flag=false;
return;
}else{
var reg=/^[0-9]*$/;
if (!reg.test(mileage)){
alert("行驶里程只能是数字");
flag=false;
return;
}
}
$(".btn").removeAttr("onclick");
if(flag){
$.ajax({
url:"/ump/xxxxpc/pageAppoint/addAppointment",
data : {
//业务字段
"memberId":'${member.id}',
...................
"mileage":mileage,
'formToken' : formToken
},
type: "POST",
error: function(msg){},
success:function(s){
var res = eval('([{'+s+'}])');
if(res[0].state){
alert(res[0].msg);
window.location.href ="/ump/xxxxpc/pageAppoint/showAppointmentList";
}else{
alert(res[0].msg);
return;
}
},
complete: comAjaxComplete //----此处为pc端ajax统一鉴权,暂时不用管
})
}
}
</script>
<div class="personal_right clearfix">
<div class="appointment">
<div class="appointment_form">
<input type="hidden" id="formToken" name="formToken" value="${formToken}"/>
<div class="form_item">
<div class="item_left">行驶里程<sup class="important_ts">*</sup>:</div>
<div class="item_right"><input id="mileage" placeholder="请输入行驶里程"/></div>
</div>
<div class="submit_re">
<input type="button" value="提交" class="submit_sub theme_c" onclick="addAppoint();"/>
<input type="button" value="返回" class="submit_sub other_c" onclick="history.back();"/>
</div>
</div>
</div>
</div>
controller表单提交方法:
@RequestMapping(value = "addAppointment", produces = "text/html;charset=utf-8")
@ResponseBody
@FormToken(remove = true, pass = true)
public String addAppointment(HttpServletRequest request, HttpServletResponse response, Model
uiModel,@RequestParam(value = "memberId", required = false) Long memberId,
@RequestParam(value = "mileage", required = false) String mileage) {
String str = "";
//判断是否为重复提交,如果是直接跳转
String passRepeat = (String) request.getAttribute("formToken_pass_repeat");
if ("true".equals(passRepeat)) {
str = "state:false,msg:"请勿重复提交!"";
return str;
}
String clinetToken= request.getParameter("formToken");
//重新生成一个uuid,预约失败的时候生成新的uuid
String uuid =clinetToken;
if (memberId == null || memberId.longValue() == 0l) {
CommonInterceptorUtil.recoveryFormToken(uuid);
str = "state:false,msg:"参数错误,请刷新后重试"";
} else if (StringUtils.isEmpty(mileage)) {
CommonInterceptorUtil.recoveryFormToken(uuid);
str = "state:false,msg:"行驶里程不能为空"";
} else {
//业务代码
......
str = "state:true,msg:"预约成功"";
}
return str;
}
CommonInterceptorUtil (提交失败时token恢复类)
package com.empire.interceptor;
import java.util.Date;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.empire.utils.RedisUtil;
/**
* 类CommonInterceptorUtil.java的实现描述:数据同步工具类
*
* @author arron 2017年3月14日 下午3:51:53
*/
public class CommonInterceptorUtil {
private static final Logger log = LoggerFactory.getLogger(CommonInterceptorUtil .class);
/**
* 恢复formToken,用于ajax防止重复提交,在controller中执行业务失败后调用
*
* @param clinetToken
*/
public static void recoveryFormToken(String clinetToken) {
if (StringUtils.isNotBlank(clinetToken)) {
RedisUtil.getRu().setex("formToken_" + clinetToken, "1", 60 * 60);
log.warn("防重复提交业务失败_恢复成功formToken:" + clinetToken);
}
}
}
RedisUtil(此处不是重点,可自行实现)
package com.empire.utils;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.log4j.Logger;
import redis.clients.jedis.BinaryClient.LIST_POSITION;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/**
* jredis工具类
*
* @author Aaron
*/
public class RedisUtil {
private static final Logger LOGGER = Logger.getLogger(RedisUtil.class);
private static JedisPool pool = null;
private static RedisUtil ru = new RedisUtil();
private RedisUtil() {
if (pool == null) {
String ip = "";
int port = 6379;
String redisIpPort = Global.REDIS_STRING;
String[] str = redisIpPort.split(";");
if (null != str) {
for (String ipAddress : str) {
if (null != ipAddress && !"".equals(ipAddress)) {
ip = ipAddress.split(":")[0];
port = Integer.parseInt(ipAddress.split(":")[1]);
}
}
}
JedisPoolConfig config = new JedisPoolConfig();
// 控制一个pool可分配多少个jedis实例,通过pool.getResource()来获取;
// 如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
config.setMaxTotal(10000);
// 控制一个pool最多有多少个状态为idle(空闲的)的jedis实例。
config.setMaxIdle(2000);
// 表示当borrow(引入)一个jedis实例时,最大的等待时间,如果超过等待时间,则直接抛出JedisConnectionException;
config.setMaxWaitMillis(1000 * 100);
config.setTestOnBorrow(true);
pool = new JedisPool(config, ip, port, 100000);
}
}
/**
* <p>
* 设置key value,如果key已经存在则返回0,nx==> not exist
* </p>
*
* @param key
* @param value
* @return 成功返回1 如果存在 和 发生异常 返回 0
*/
public Long setnx(byte[] key, byte[] value) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.setnx(key, value);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return 0L;
} finally {
returnResource(pool, jedis);
}
}
/**
* 设置有效时间
*
* @param key
* @param seconds单位:秒
* @return
*/
public Long expire(byte[] key, int seconds) {
Jedis jedis = null;
Long res = null;
try {
jedis = pool.getResource();
res = jedis.expire(key, seconds);
} catch (Exception e) {
LOGGER.error(e.getMessage());
} finally {
returnResource(pool, jedis);
}
return res;
}
/**
* <p>
* 向redis存入key和value,并释放连接资源
* </p>
* <p>
* 如果key已经存在 则覆盖
* </p>
*
* @param key
* @param value
* @return 成功 返回OK 失败返回 0
*/
public String set(byte[] key, byte[] value) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.set(key, value);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return "0";
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 通过key获取储存在redis中的value
* </p>
* <p>
* 并释放连接
* </p>
*
* @param key
* @return 成功返回value 失败返回null
*/
public byte[] get(byte[] key) {
Jedis jedis = null;
byte[] value = null;
try {
jedis = pool.getResource();
value = jedis.get(key);
} catch (Exception e) {
LOGGER.error(e.getMessage());
} finally {
returnResource(pool, jedis);
}
return value;
}
/**
* <p>
* 通过key获取储存在redis中的value
* </p>
* <p>
* 并释放连接
* </p>
*
* @param key
* @return 成功返回value 失败返回null
*/
public String get(String key) {
Jedis jedis = null;
String value = null;
try {
jedis = pool.getResource();
value = jedis.get(key);
} catch (Exception e) {
LOGGER.error(e.getMessage());
} finally {
returnResource(pool, jedis);
}
return value;
}
/**
* <p>
* 向redis存入key和value,并释放连接资源
* </p>
* <p>
* 如果key已经存在 则覆盖
* </p>
*
* @param key
* @param value
* @return 成功 返回OK 失败返回 0
*/
public String set(String key, String value) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.set(key, value);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return "0";
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 删除指定的key,也可以传入一个包含key的数组
* </p>
*
* @param keys 一个key 也可以使 string 数组
* @return 返回删除成功的个数
*/
public Long del(byte[]... keys) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.del(keys);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return 0L;
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 删除指定的key,也可以传入一个包含key的数组
* </p>
*
* @param keys 一个key 也可以使 string 数组
* @return 返回删除成功的个数
*/
public Long del(String... keys) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.del(keys);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return 0L;
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 通过key向指定的value值追加值
* </p>
*
* @param key
* @param str
* @return 成功返回 添加后value的长度 失败 返回 添加的 value 的长度 异常返回0L
*/
public Long append(String key, String str) {
Jedis jedis = null;
Long res = null;
try {
jedis = pool.getResource();
res = jedis.append(key, str);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return 0L;
} finally {
returnResource(pool, jedis);
}
return res;
}
/**
* <p>
* 判断key是否存在
* </p>
*
* @param key
* @return true OR false
*/
public Boolean exists(String key) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.exists(key);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return false;
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 设置key value,如果key已经存在则返回0,nx==> not exist
* </p>
*
* @param key
* @param value
* @return 成功返回1 如果存在 和 发生异常 返回 0
*/
public Long setnx(String key, String value) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.setnx(key, value);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return 0L;
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 设置key value并制定这个键值的有效期
* </p>
*
* @param key
* @param value
* @param seconds 单位:秒
* @return 成功返回OK 失败和异常返回null
*/
public String setex(String key, String value, int seconds) {
Jedis jedis = null;
String res = null;
try {
jedis = pool.getResource();
res = jedis.setex(key, seconds, value);
} catch (Exception e) {
LOGGER.error(e.getMessage());
} finally {
returnResource(pool, jedis);
}
return res;
}
/**
* 设置有效时间
*
* @param key
* @param seconds单位:秒
* @return
*/
public Long expire(String key, int seconds) {
Jedis jedis = null;
Long res = null;
try {
jedis = pool.getResource();
res = jedis.expire(key, seconds);
} catch (Exception e) {
LOGGER.error(e.getMessage());
} finally {
returnResource(pool, jedis);
}
return res;
}
/**
* <p>
* 通过key 和offset 从指定的位置开始将原先value替换
* </p>
* <p>
* 下标从0开始,offset表示从offset下标开始替换
* </p>
* <p>
* 如果替换的字符串长度过小则会这样
* </p>
* <p>
* example:
* </p>
* <p>
* value : bigsea@zto.cn
* </p>
* <p>
* str : abc
* </p>
* <P>
* 从下标7开始替换 则结果为
* </p>
* <p>
* RES : bigsea.abc.cn
* </p>
*
* @param key
* @param str
* @param offset 下标位置
* @return 返回替换后 value 的长度
*/
public Long setrange(String key, String str, int offset) {
Jedis jedis = null;
try {
jedis = pool.getResource();
return jedis.setrange(key, offset, str);
} catch (Exception e) {
LOGGER.error(e.getMessage());
return 0L;
} finally {
returnResource(pool, jedis);
}
}
/**
* <p>
* 通过批量的key获取批量的value
* </p>
*
* @param keys string数组 也可以是一个key
* @return 成功返回value的集合, 失败返回null的集合 ,异常返回空
*/
public List<String> mget(String... keys) {
Jedis jedis = null;
List<String> values = null;
try {
jedis = pool.getResource();
values = jedis.mget(keys);
} catch (Exception e) {
LOGGER.error(e.getMessage());
} finally {
returnResource(pool, jedis);
}
return values;
}
/**
* <p>
* 批量的设置key:value,可以一个
* </p>
* <p>
* example:
* </p>
* <p>
* obj.mset(new String[]{"key2","value1","key2","value2"})
* </p>
*
* @param keysvalues
* @return 成功返回OK 失败 异常 返回 null
*/
public String mset(String... keysvalues) {
Jedis jedis = null;
String res = null;
try {
jedis = pool.getResource();
res = jedis.mset(keysvalues);
} catch (