併發請求的重複插入問題

weixin_30639719發表於2020-04-05

  最近被一個併發問題折騰的很慘,特意拿出來分享。把我不開心的事,發出來給大家開心開心。

  業務背景:邀請活動,一個使用者可以邀請多個使用者,比如我可以邀請你,也可以邀請他。但一個使用者只能被另一個使用者邀請,不允許重複邀請。比如你邀請了我,他就不能再邀請我了。

  問題背景:根據業務背景設計了一張被邀請人的表來儲存被邀請人記錄。重複邀請的判斷是拿活動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個查到庫裡已經有資料直接返回成功了,我這裡就沒全部貼出來了。

轉載於:https://www.cnblogs.com/wuxun1997/p/9621693.html

相關文章