使用乐观锁方式修复红包超发的bug

乐观锁

乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,它的设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,也称之为为非阻塞锁。 乐观锁使用的是 CAS原理

CAS 原理

  • 在 CAS 原理中,对于多个线程共同的资源,先保存一个旧(Old Value),比如进入线程后,查询当前存量为 100 个红包,那么先把旧值保存为 100,然后经过一定的逻辑处理。

  • 当需要扣减红包的时候,先比较数据库当前的值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过了,不再进行操作。

CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次 比较,即比较各个线程当前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该重试,这样就是一个可重入锁,但是 CAS 原理会有一个问题,那就是 ABA 问题

ABA问题

在处理复杂运算的时候,被线程 2 修改的 X 的值有可能导致线程1的运算出错,而最后线程 2 将 X 的值修改为原来的旧值 A,那么到了线程 1运算结束的时间顺序 T6,它将j检测 X 的值是否发生变化,就会拿旧值 A 和 当前的 X 的值 A 比对 , 结果是一致的, 于是提交事务,然后在复杂计算的过程中 X 被线程 2 修改过了,这会导致线程1的运算出错。

在这个过程中,对于线程 2 而言 , X 的值的变化为 A->B->A,所以 CAS 原理的这个设计缺陷被形象地称为“ABA 问题”。

ABA 问题的发生 , 是因为业务逻辑存在回退的可能性 。 如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号( version ),对于版本号有一个约定,就是只要修改 X变量的数据,强制版本号( version )只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了。

只是这个 version 变量并不存在什么业务逻辑,只是为了记录更新次数,只能递增,帮助我们克服 ABA 问题罢了 , 有了这些理论 , 我们就可以开始使用乐观锁来完成抢红包业务了 。

添加version字段

在第一步已添加

改造Dao层

TRedPacketMapper.java

 //乐观锁更新版本信息
    int decreaseRedPacketForVersion(@Param("id") Integer id, @Param("version") Integer version);

TRedPacketMapper.xml

<!-- 通过版本号扣减抢红包 每更新一次,版本增1, 其次增加对版本号的判断 -->
    <update id="decreaseRedPacketForVersion">
        update 
            T_RED_PACKET 
        set stock = stock - 1 ,
            version = version + 1
        where id = #{id} 
        and version = #{version}
    </update>

在扣减红包的时候 , 增加了对版本号的判断,其次每次扣减都会对版本号加一,这样保证每次更新在版本号上有记录 , 从而避免 ABA 问题

对于查询也不使用 for update 语句 , 避免锁的发生 , 这样就没有线程阻塞的问题了。 然后就可 以在类UserRedPacketServic接口中新增方法 grapRedPacketForVersion,然后在其实现类中完成对应的逻辑即可。

Service层改造

UserRedPacketService.java

/**
     * 保存抢红包信息. 乐观锁的方式
     * @param redPacketId 红包编号
     * @param userId 抢红包用户编号
     * @return 影响记录数.
     */
    public int grapRedPacketForVersion(Integer redPacketId, Integer userId);

UserRedPacketServiceImpl.java

package com.redpacket.ssm.service.impl;

import java.math.BigDecimal;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;

import com.redpacket.ssm.mapper.TRedPacketMapper;
import com.redpacket.ssm.mapper.TUserRedPacketMapper;
import com.redpacket.ssm.po.TRedPacket;
import com.redpacket.ssm.po.TUserRedPacket;
import com.redpacket.ssm.service.UserRedPacketService;

public class UserRedPacketServiceImpl implements UserRedPacketService {
        @Autowired
        private TRedPacketMapper tRedPacketMapper;

        @Autowired
        private TUserRedPacketMapper tUserRedPacketMapper;

        // 失败
        final int FAILED = 0;
        @Override
        public int grapRedPacketForVersion(Integer redPacketId, Integer userId) {
            // 获取红包信息
            TRedPacket tRedPacket = tRedPacketMapper.getRedPacketForUpdate(redPacketId);
            // 当前小红包库存大于0
            if (tRedPacket.getStock() > 0) {
                // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
                int update = tRedPacketMapper.decreaseRedPacketForVersion(redPacketId, tRedPacket.getVersion());
                // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
                if (update == 0) {
                    return FAILED;
                }
                // 生成抢红包信息
                TUserRedPacket tUserRedPacket = new TUserRedPacket();
                tUserRedPacket.setRedPacketId(redPacketId);
                tUserRedPacket.setUserId(userId);
                tUserRedPacket.setAmount(new BigDecimal(tRedPacket.getUnitAmount()));
                tUserRedPacket.setNote("redpacket- " + redPacketId);
                // 插入抢红包信息
                int result = tUserRedPacketMapper.grapRedPacket(tUserRedPacket);
                return result;
            }
            // 失败返回
            return FAILED;
        }

}

version 值一开始就保存到了对象中,当扣减的时候,再次传递给 SQL ,让 SQL 对数据库的 version 和当前线程的旧值 version 进行比较。如果一致则插入抢红包的数据,否则就不进行操作

controller层新增方法

package com.redpacket.ssm.controller;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.redpacket.ssm.service.UserRedPacketService;

@Controller
@RequestMapping("/userRedPacket")
public class UserRedPacketController {

    @Autowired
    private UserRedPacketService userRedPacketService;
    
    @RequestMapping("/grapRedPacketForVersion")
    public @ResponseBody Map<String, Object> grapRedPacketForVersion(Integer redPacketId, Integer userId) {
        // 抢红包
        int result = userRedPacketService.grapRedPacketForVersion(redPacketId, userId);
        Map<String, Object> retMap = new HashMap<String, Object>();
        boolean flag = result > 0;
        retMap.put("success", flag);
        retMap.put("message", flag ? "抢红包成功" : "抢红包失败");
        return retMap;
    }   

}

view层新建一个jsp

redpacketByVerson.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
         pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <title>参数</title>
        <!-- 加载Query文件-->
        <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.0.js">
        </script>
        <script type="text/javascript">
            $(document).ready(function () {
              //模拟30000个异步请求,进行并发
              var max = 30000;
              for (var i = 1; i <= max; i++) {
                  //jQuery的post请求,请注意这是异步请求
                  $.post({
                      //请求抢id为1的红包
                      //根据自己请求修改对应的url和大红包编号
                      url: "${pageContext.request.contextPath }/userRedPacket/grapRedPacketForVersion.action?redPacketId=1&userId=" + i,
                      //成功后的方法
                      success: function (result) {
                      }
                  });
              }
          });
        </script>
    </head>
    <body>
    
    haha
    
    </body>
</html>

初始化数据开始测试

经过 3 万次的抢夺,一共抢到了7088个红包,剩余12912个红包, 也就是存在大量的因为版本不一致的原因造成抢红包失败的请求。 这失败率太高了。。
有时候会容忍这个失败,这取决于业务的需要,因为允许用户自己再发起抢夺红包。

解决因version导致失败问题

为提高成功率,可以考虑使用重入机制 。 也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入会造成大量的 SQL 执行,所以目前流行的重入会加入两种限制

  • 一种是按时间戳的重入,也就是在一定时间戳内(比如说 100毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。

  • 一种是按次数,比如限定 3 次,程序尝试超过 3 次抢红包后,就判定请求失效,这样有助于提高用户抢红包的成功率。

乐观锁重入机制-按时间戳重入

因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入,把 UserRedPacketServicelmpl 中的方法grapRedPacketForVersion 修改下

@Override
        public int grapRedPacketForVersion(Integer redPacketId, Integer userId) {
            // 记录开始时间
            long start = System.currentTimeMillis();
            // 无限循环,等待成功或者时间满100毫秒退出
            while (true) {
                // 获取循环当前时间
                long end = System.currentTimeMillis();
                // 当前时间已经超过100毫秒,返回失败
                if (end - start > 100) {
                    return FAILED;
                }
                // 获取红包信息,注意version值
                TRedPacket tRedPacket = tRedPacketMapper.selectByPrimaryKey(redPacketId);
                // 当前小红包库存大于0
                if (tRedPacket.getStock() > 0) {
                    // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
                    int update = tRedPacketMapper.decreaseRedPacketForVersion(redPacketId, tRedPacket.getVersion());
                    // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
                    if (update == 0) {
                        continue;
                    }
                    // 生成抢红包信息
                    TUserRedPacket tUserRedPacket = new TUserRedPacket();
                    tUserRedPacket.setRedPacketId(redPacketId);
                    tUserRedPacket.setUserId(userId);
                    tUserRedPacket.setAmount(new BigDecimal(tRedPacket.getUnitAmount()));
                    tUserRedPacket.setNote("redpacket- " + redPacketId);
                    // 插入抢红包信息
                    int result = tUserRedPacketMapper.grapRedPacket(tUserRedPacket);
                    
                    return result;
                } else {
                    // 一旦没有库存,则马上返回
                    return FAILED;
                }
            }
        }

当因为版本号原因更新失败后,会重新尝试抢夺红包,但是会实现判断时间戳,如果时间戳在 100 毫秒内,就继续,否则就不再重新尝试,而判定失败,这样可以避免过多的SQL 执行 , 维持系统稳定。

从结果来看,之前大量失败的场景消失了,也没有超发现象 , 3 万次尝试抢光了所有的红包 , 避免了总是失败的结果,但是有时候时间戳并不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不一。有时候我们也会考虑、限制重试次数,比如 3 次,如下所示

乐观锁重入机制-按次数重入

UserRedPacketServiceImpl.java

@Override
        public int grapRedPacketForVersion(Integer redPacketId, Integer userId) {
            for (int i = 0; i < 3; i++) {
                // 获取红包信息,注意version值
                TRedPacket tRedPacket = tRedPacketMapper.selectByPrimaryKey(redPacketId);
                // 当前小红包库存大于0
                if (tRedPacket.getStock() > 0) {
                    // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
                    int update = tRedPacketMapper.decreaseRedPacketForVersion(redPacketId, tRedPacket.getVersion());
                    // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
                    if (update == 0) {
                        continue;
                    }
                    // 生成抢红包信息
                    TUserRedPacket tUserRedPacket = new TUserRedPacket();
                    tUserRedPacket.setRedPacketId(redPacketId);
                    tUserRedPacket.setUserId(userId);
                    tUserRedPacket.setAmount(new BigDecimal(tRedPacket.getUnitAmount()));
                    tUserRedPacket.setNote("redpacket- " + redPacketId);
                    // 插入抢红包信息
                    int result = tUserRedPacketMapper.grapRedPacket(tUserRedPacket);
                    
                    return result;
                } else {
                    // 一旦没有库存,则马上返回
                    return FAILED;
                }
            }
            return FAILED;
        }

初始化数据,再次测试。

3 万次请求,所有红包都被抢到了 , 也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。但是时间受机器影响,不确定性很高,尤其是我得机子还经常崩溃。。。。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容