分佈式系統後臺如何防止重複提交
分佈式系統網絡拓撲結構
場景描述
秒殺系統提交訂單時,由於用戶連續快速點擊,並且前端沒有針對性處理,導致連續發送兩次請求,一次命中服務器A,另一次命中服務器B, 那麼就生成了兩個內容完全相同的訂單,只是訂單號不同而已.
重複提交的後果
- 用戶在界面看到兩個一模一樣的訂單,不知道應該支付哪個;
- 系統出現異常數據,影響正常的校驗.
解決方法
解決思路:相同的請求在同一時間只能被處理一次.
如果是單體服務器,我們可以通過多線程併發的方式解決,但是目前大部分系統,採用了多機負載均衡.
分佈式鎖
- 服務器A接收到請求之後,獲取鎖,獲取成功 ,
- 服務器A進行業務處理,訂單提交成功;
- 服務器B接收到相同的請求,獲取鎖,失敗,
因爲鎖被服務器A獲取了,並且未釋放 - 服務器A處理完成,釋放鎖 實現採用redis ,
參考:http://www.importnew.com/27477.html
利用數據庫唯一性約束
實現思路: 對請求信息進行hash運算,得到一個hash值,
相同的請求信息得到相同的hash值(換成md5也可以) 步驟:
- 接口A接收到請求之後,對請求信息hash運算,得到hash值hashCodeA;
- 保存hashCodeA 到數據庫,並且對應的數據庫的列(column)滿足unique約束;
- 保存成功之後,才進行正常業務邏輯處理,比如提交訂單;
- 服務器B接收到相同的請求後,也得到相同的hash值,hashCodeA,
- 服務器B 保存hashCodeA 到數據庫,肯定失敗,因爲相同的hash值已經存在;
- 因爲保存失敗,所以後面的業務邏輯不會執行.
示例代碼: 控制器中:
// 使用數據庫約束條件,防止重複提交
try {
Integer userId = getCurrentId();
String hashSource = WebServletUtil.buildHashSource(request, userId);
reqOrderLock(hashSource, houseInfo.getId());
} catch (IOException e) {
e.printStackTrace();
}
Service中:
/***
* 利用數據庫的唯一性約束<br />
* 防止重複提交
* @param queryString
*/
public void reqOrderLock(String queryString, Integer houseInfoId) {
long crc32Long = EncryptionUtil.getHash(queryString);
this.orderReqLockDao.addUnique(String.valueOf(crc32Long), houseInfoId, Constant2.Order_type_Label_VISIT_ORDER);
}
dao 中:
public OrderReqLock addUnique(String crc32, Integer houseInfoId, String orderTypeLabel) {
OrderReqLock orderReqLock = new OrderReqLock();
orderReqLock.setCrc32(crc32);
if (null != houseInfoId) {
orderReqLock.setHouseInfoId(houseInfoId);
}
orderReqLock.setOrderTypeLabel(orderTypeLabel);
CreateTimeDto createTimeDto = TimeHWUtil.getCreateTimeDao();
orderReqLock.setCreateTime(createTimeDto.getCreateTime());
orderReqLock.setUpdateTime(createTimeDto.getUpdateTime());
try {
add(orderReqLock);
} catch (org.hibernate.exception.ConstraintViolationException e) {
e.printStackTrace();
LogicExc.throwEx(Constant2.ERROR_CODE_Repeat_Operation, "您重複提交了訂單,訂單類型" );
}
return orderReqLock;
}
當然有個隱患: 在增加完鎖,即執行addUnique 方法之後,程序掛了,不管是網絡原因還是數據庫崩潰, 當服務恢復之後,相同的請求無法提交了,因爲數據庫已經保存了請求的hash(但是實際上,後面的業務邏輯還沒有來得及執行). 原因:鎖操作addUnique 和業務邏輯肯定不在同一個數據庫事務中
前端的解決方法
思路: 進入添加頁面時,獲取服務器端的token,
提交時把token提交過去,判斷token是否存在,
若存在,則進行後續正常業務邏輯,
如不存在,則報錯重複提交.
流程圖
添加頁面接口 使用註解:@RepeatToken(save = true) 提交接口 使用註解 :@RepeatToken(remove = true) token 攔截器代碼
package com.girltest.web.controller.intercept;
import com.common.util.WebServletUtil;
import org.apache.log4j.Logger;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.UUID;
/**
* Created by 黃威 on 9/14/16.<br >
*/
public class TokenInterceptor extends HandlerInterceptorAdapter {
private static final Logger LOG = Logger.getLogger(TokenInterceptor.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();
RepeatToken annotation = method.getAnnotation(RepeatToken.class);
if (annotation != null) {
boolean needSaveSession = annotation.save();
if (needSaveSession) {
request.getSession(true).setAttribute("token", UUID.randomUUID().toString());
}
boolean needRemoveSession = annotation.remove();
if (needRemoveSession) {
if (isRepeatSubmit(request)) {
LOG.warn("please don't repeat submit,url:" + request.getServletPath());
//如果重複提交,則重定向到列表頁面
response.sendRedirect(WebServletUtil.getBasePath(request) + "test/list");
return false;
}
request.getSession(true).removeAttribute("token");
}
}
return true;
} else {
return super.preHandle(request, response, handler);
}
}
/***
*
* @param request
* @return : true:報錯需要重定向 <br />
* false: 處理後續的正常業務邏輯
*/
private boolean isRepeatSubmit(HttpServletRequest request) {
String serverToken = (String) request.getSession(true).getAttribute("token");
if (serverToken == null) {
return true;
}
String clinetToken = request.getParameter("token");
if (clinetToken == null) {
return true;
}
if (!serverToken.equals(clinetToken)) {
return true;
}
return false;
}
}
總結
- 第二種方法(利用數據庫完整性約束)最簡便,但是會訪問(讀寫)數據庫,給數據庫造成一定的壓力;
但也有個隱患,程序執行中途故障了(網絡垮了,服務宕了...),後面重複提交,就無法成功了.也有解決方法:定時器清理這個hash數據庫表 - 第一種方法最複雜,但是符合高性能,高可用
參考
https://my.oschina.net/huangweiindex/blog/1837706
參考:http://www.importnew.com/27477.html
https://my.oschina.net/huangweiindex/blog/1843927