并发请求的重复插入问题

  最近被一个并发问题折腾的很惨,特意拿出来分享。把我不开心的事,发出来给大家开心开心。

  业务背景:邀请活动,一个用户可以邀请多个用户,比如我可以邀请你,也可以邀请他。但一个用户只能被另一个用户邀请,不允许重复邀请。比如你邀请了我,他就不能再邀请我了。

  问题背景:根据业务背景设计了一张被邀请人的表来存储被邀请人记录。重复邀请的判断是拿活动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("myInviteeService")
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="insertMyInviteeRecord" parameterType="com.wlf.domain.vcode.Invitee">
        merge into t_my_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
     *
     * @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请求
     *
     * @version C10 2018年9月4日*/
    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个查到库里已经有数据直接返回成功了,我这里就没全部贴出来了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值