最近被一個併發問題折騰的很慘,特意拿出來分享。把我不開心的事,發出來給大家開心開心。
業務背景:邀請活動,一個使用者可以邀請多個使用者,比如我可以邀請你,也可以邀請他。但一個使用者只能被另一個使用者邀請,不允許重複邀請。比如你邀請了我,他就不能再邀請我了。
問題背景:根據業務背景設計了一張被邀請人的表來儲存被邀請人記錄。重複邀請的判斷是拿活動ID和被邀請人查表,存在說明被邀請人重複了。但如果是併發重複請求,會突破這一層校驗,因為那時資料未入庫,根本就查不到。所以在表加了唯一索引:邀請人號碼、被邀請人號碼和活動ID,這樣一來,同一活動、相同的邀請人和被邀請人將無法同時入庫,確保了被邀請人在併發重複請求時只有一條記錄插入。
問題:需求變更,現在允許重複邀請了,比如你邀請了我,他也能再次邀請我。很明顯唯一索引必須要修改,否則需求無法實現。為了繼續使用唯一索引來限制併發重複請求,我們可以給它加一個邀請時間欄位,這樣同一個時間點的併發重複請求會被限制。那麼現在問題來了,雖然限制住了同一秒(邀請時間欄位精確到秒)的併發重複請求,但並不能限制住不同秒級的併發。比如兩條併發,第一條是2018-9-10 17:24:00入庫的,第二條是2018-9-10 17:24:01入庫的。假如是100條併發,那麼跨秒的可能性更大。
解決方案:
1、前端限制:點選按鈕觸發事件後把按鈕屬性設定為disable,限制重複點選。或者點選按鈕後播放一個3秒倒數計時,這3秒內使用者也無法重複請求。遺憾的是這個業務場景是二維碼掃碼觸發的,所以拿兩個手機對著同一個二維碼掃就可能併發了。
2、後端限制:插入前先查,查不到插,程式碼加鎖。這樣能限制住單點的併發,但生產環境部署了好幾臺機子做負載均衡,也就是併發請求可能同時到達兩臺不同的機子。這種分散式的情況下,得加分散式鎖才行。遺憾的是這個專案並未使用redis。
訊息佇列,把併發請求放進佇列裡,然後一個一個處理,如果是重複請求就過濾掉。基本原理還是把併發變成同步。遺憾的是該專案未使用kafka或其他mq。
3、資料庫限制:先考慮了事務,該專案資料庫是Oracle,採用了myBatis作為ORM框架,採用預設的事務隔離級別READ COMMITTED,又試了序列化的SERIALIZABLE,結果都不行。目前仍不清楚是否為myBatis造成的,它的事務是由spring的切面切進來的。先通過註解@Service註冊到spring的容器,再由切面expression匹配,不知道是否在insertInviteeRecord(插入)呼叫了getInviteeCountForOneCampaign(查詢)造成的。貼上程式碼:
import java.sql.SQLException; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.wlf.dao.InviteeMapper; import com.wlf.domain.vcode.Invitee; import com.wlf.domain.vcode.InviterBase; import com.wlf.service.inviteVcode.InviteeService; @Service("inviteeService") public class InviteeServiceImpl implements InviteeService { @Autowired private InviteeMapper inviteeMapper; @Override public Integer getInviteeCountForOneCampaign(String campaignId, String inviteeIdentityId) { return inviteeMapper.getInviteeCountForOneCampaign(campaignId, inviteeIdentityId); } @Override public void insertInviteeRecord(Invitee invitee) { if (inviteeMapper.getInviteeCountForOneCampaign(invitee.getActivityId(), invitee.getInviteeMsisdn()) > 0) { throw new RuntimeException("併發了併發了"); } else { inviteeMapper.insertInviteeRecord(invitee); } } }
<!-- 攔截器方式配置事物 --> <tx:advice id="transactionAdvice" transaction-manager="transactionManager"> <tx:attributes> <tx:method name="insertInviteeRecord" propagation="REQUIRED" isolation="SERIALIZABLE"/> </tx:attributes> </tx:advice> <aop:config> <aop:pointcut id="transactionPointcut" expression="execution(* com.wlf.service..*Impl.*(..))" /> <aop:advisor pointcut-ref="transactionPointcut" advice-ref="transactionAdvice" /> </aop:config>
又考慮了悲觀鎖和樂觀鎖。遺憾的是這裡是插入的併發,而不是修改。併發請求還未到來前,表裡並無資料,所以無法使用for update來鎖住記錄,也無法加版本或者時間戳欄位來標誌記錄。
儲存過程和觸發器太麻煩,pass了。最後採用了merge into:
<!-- 插入一條被邀請記錄 --> <insert id="insertInviteeRecord" parameterType="com.wlf.domain.vcode.Invitee"> merge into t_invitee_record t1 using (select #{inviteeMsisdn,jdbcType=VARCHAR} inviteeMsisdn,#{inviterMsisdn,jdbcType=VARCHAR} inviterMsisdn,#{activityId,jdbcType=VARCHAR} activityId from dual) t2 on (t1.inviteeMsisdn = t2.inviteeMsisdn and t1.inviterMsisdn = t2.inviterMsisdn and t1.activityId = t2.activityId) when not matched then INSERT (inviteeMsisdn,inviterMsisdn,activityId,acceptInviteTime) VALUES( #{inviteeMsisdn,jdbcType=VARCHAR}, #{inviterMsisdn,jdbcType=VARCHAR}, #{activityId,jdbcType=VARCHAR}, #{acceptInviteTime,jdbcType=TIMESTAMP} ) </insert>
先select一把,把select到的資料放在dual裡,再跟要插入的資料匹配。如果能匹配上,說明表裡已經有其他併發請求捷足先登了,匹配不上說明我先來,直接插入。這種語句應該算會話級別的防併發控制,可以過濾掉大部分併發請求,但不能識別出併發時間很短的請求,這種併發就需要唯一索引發揮威力了。
最後看下測試結果:
import java.nio.charset.Charset; import org.asynchttpclient.AsyncHttpClient; import org.asynchttpclient.AsyncHttpClientConfig; import org.asynchttpclient.BoundRequestBuilder; import org.asynchttpclient.DefaultAsyncHttpClient; import org.asynchttpclient.DefaultAsyncHttpClientConfig; import org.asynchttpclient.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; public class HttpTools { /** * http的header中的content-type屬性的名字 */ private static final String CONTENT_TYPE_NAME = "content-type"; /** * http的header中的content-type屬性的內容 */ private static final String CONTENT_TYPE_VALUE_XML_UTF_8 = "application/json; charset=UTF-8"; /** * http的header中的content-type屬性的字元編碼 */ private static final String UTF_8 = "UTF-8"; /** * HTTP 成功響應結果碼 */ private static final int HTTP_STATUS_OK = 200; /** * HttpUtil類的例項 */ private static HttpTools instance = new HttpTools(); /** * 日誌物件 */ private static final Logger LOGGER = LoggerFactory.getLogger(HttpTools.class); /** * server 其他錯誤錯誤碼 */ private final static int SERVER_OTHER_ERROR_CODE = 20000; /** * HttpUtil類建構函式 */ public HttpTools() { } public static HttpTools getInstance() { return instance; } private static AsyncHttpClient asynHttpClient = getAsyncHttpClient(); /** * 獲取請求類的客戶端 */ public static AsyncHttpClient getAsyncHttpClient() { AsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder().setFollowRedirect(false) .setConnectTimeout(PropertiesConfig.getInt("asynHttp.connectTimeout", 500)) .setRequestTimeout(PropertiesConfig.getInt("asynHttp.requestTimeout", 10000)) .setReadTimeout(PropertiesConfig.getInt("asynHttp.readTimeout", 10000)) .build(); AsyncHttpClient client = new DefaultAsyncHttpClient(config); return client; } /** * @param url * @param xml */ public static String sendRequestByAsync(String url, String xml) { if (LOGGER.isDebugEnabled()) { LOGGER.debug("Enter sendRequestByAsync()! url=" + url + "and xml=" + xml); } // 預設響應結果碼 int resultCode = HTTP_STATUS_OK; Response response = null; String responseXml = null; BoundRequestBuilder builder = asynHttpClient.preparePost(url); try { // 把引數放入請求頭header中 builder.setHeader(CONTENT_TYPE_NAME, CONTENT_TYPE_VALUE_XML_UTF_8); // 請求訊息體 builder.setBody(xml); // 傳送http請求 response = asynHttpClient.executeRequest(builder.build()).get(); if (null == response) { LOGGER.error("The response code is error! response is null and url=" + url + "and xml=" + xml); return null; } resultCode = response.getStatusCode(); if (HTTP_STATUS_OK != resultCode) { if (SERVER_OTHER_ERROR_CODE == resultCode) { LOGGER.error("The response code is error!and url=" + url + "and xml=" + xml + "and resuleCode=" + resultCode); } else { if (LOGGER.isInfoEnabled()) { LOGGER.info("The response code is error!and url=" + url + "and xml=" + xml + "and resuleCode=" + resultCode); } } } responseXml = response.getResponseBody(Charset.forName(UTF_8)); } catch (Exception ex) { LOGGER.error( "send http request error in BaseHttpTools.sendHttpRequestByAsync(String url, String xml)!errorMessage=" + ex.getMessage() + "||url=" + url + "||xml=" + xml, ex); } return responseXml; } public static void main(String[] args) { HttpTools ht = new HttpTools(); try { int nThreads = 100; String url = "http://127.0.0.1:8088/wlf/invite"; String xml = createXml(); ht.httpPost(url, xml, nThreads); } catch (Exception e) { e.printStackTrace(); } } /** * 構造請求xml報文 * * @author wulinfeng * @return */ private static String createXml() { StringBuilder strBuf = new StringBuilder(); strBuf.append("<Request>"); strBuf.append("<activityId>").append("4001").append("</activityId>"); strBuf.append("<inviteeId>").append("13824384878").append("</inviteeId>"); strBuf.append("<inviterId>").append("40000580417").append("</inviterId>"); strBuf.append("<acceptTime>").append("20180904094912").append("</acceptTime>"); strBuf.append("</Request>"); return strBuf.toString(); } /** * 開始新增執行緒呼叫http * * @author wulinfeng * @param url * @param xml * @param nThreads 啟用多少個執行緒 */ private void httpPost(String url, String xml, int nThreads) { HttpPostClient hp = new HttpPostClient(url, xml); for (int i = 0; i < nThreads; i++) { new Thread(hp).start(); } } /** * 非同步呼叫post請求 * * @author wulinfeng * @version C10 2018年9月4日 * @since SDP V300R003C10 */ class HttpPostClient implements Runnable { private String url; private String xml; public HttpPostClient(String url, String xml) { this.url = url; this.xml = xml; } @Override public void run() { String result = sendRequestByAsync(url, xml); System.out.println(result); } } }
控制檯輸出:
ERROR StatusLogger No log4j2 configuration file found. Using default configuration: logging only errors to the console. Set system property 'log4j2.debug' to show Log4j2 internal initialization logging. <?xml version="1.0" encoding="UTF-8" ?> <Response> <resultCode>20000</resultCode> <resultMsg>其他錯誤</resultMsg> </Response> <?xml version="1.0" encoding="UTF-8" ?> <Response> <resultCode>200</resultCode> <resultMsg>成功</resultMsg> </Response> <?xml version="1.0" encoding="UTF-8" ?> <Response> <resultCode>200</resultCode> <resultMsg>成功</resultMsg> </Response>
資料庫查了下,只有一條入庫了。第一個請求報錯是因為唯一索引導致的,其他99個查到庫裡已經有資料直接返回成功了,我這裡就沒全部貼出來了。