高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug

导读


image.png


高并发-【抢红包案例】之一:SSM环境搭建及复现红包超发问题

高并发-【抢红包案例】之二:使用悲观锁方式修复红包超发的bug

接下来我们使用乐观锁的方式来修复红包超发的bug


乐观锁


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


CAS 原理


Redis-11使用 watch 命令监控事务 中也介绍了CAS,这里再重新说下


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

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


CAS 原理流程如下:


20181009160529926.png



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


ABA问题


20181009160919258.png


在处理复杂运算的时候,被线程 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 问题罢了 , 有了这些理论 , 我们就可以开始使用乐观锁来完成抢红包业务了 。


库表改造


为了顺利使用乐观锁 , 需要先在红包表 C T RED PACKET ) 加入一个新的列版本号(version),这个字段在建表的时候已经建了 , 只是我们还没有使用 。 这是第一步


代码改造

既然库表加上了Version字段,那么应用中肯定要用到,自然而言的落到了Dao层上。


RedPacketDao新增接口方法及Mapper映射文件

RedPacketDao.java

  /**
   * @Description: 扣减抢红包数. 乐观锁的实现方式
   * 
   * @param id
   *            -- 红包id
   * @param version
   *            -- 版本标记
   * 
   * @return: 更新记录条数
   */
  public int decreaseRedPacketForVersion(@Param("id") Long id, @Param("version") Integer version);


RedPacket.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,然后在其实现类中完成对应的逻辑即可。


UserRedPacketServic接口及实现类的改造

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


实现类

/**
   * 乐观锁,无重入
   * */
  @Override
  @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
  public int grapRedPacketForVersion(Long redPacketId, Long userId) {
    // 获取红包信息
    RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
    // 当前小红包库存大于0
    if (redPacket.getStock() > 0) {
      // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
      int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
      // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
      if (update == 0) {
        return FAILED;
      }
      // 生成抢红包信息
      UserRedPacket userRedPacket = new UserRedPacket();
      userRedPacket.setRedPacketId(redPacketId);
      userRedPacket.setUserId(userId);
      userRedPacket.setAmount(redPacket.getUnitAmount());
      userRedPacket.setNote("redpacket- " + redPacketId);
      // 插入抢红包信息
      int result = userRedPacketDao.grapRedPacket(userRedPacket);
      return result;
    }
    // 失败返回
    return FAILED;
  }


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


Controller层新增路由方法


为了方便区分测试,在控制器 UserRedPacketController 内新建映射

@RequestMapping(value = "/grapRedPacketForVersion")
  @ResponseBody
  public Map<String, Object> grapRedPacketForVersion(Long redPacketId, Long 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吧 , 注意POST 请求地址和红包id 。

grapForVersion.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: "./userRedPacket/grapRedPacketForVersion.do?redPacketId=1&userId=" + i,
                      //成功后的方法
                      success: function (result) {
                      }
                  });
              }
          });
        </script>
    </head>
    <body>
    </body>
</html>



初始化数据,启动应用测试

一致性数据统计:


20181009213216484.png


经过 3 万次的抢夺,一共抢到了7521个红包,剩余12479个红包, 也就是存在大量的因为版本不一致的原因造成抢红包失败的请求。 这失败率太高了。。

有时候会容忍这个失败,这取决于业务的需要,因为允许用户自己再发起抢夺红包。


性能数据统计:


20181009213436902.png


解决因version导致失败问题


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


  1. 一种是按时间戳的重入,也就是在一定时间戳内(比如说 100毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。
  2. 一种是按次数,比如限定 3 次,程序尝试超过 3 次抢红包后,就判定请求失效,这样有助于提高用户抢红包的成功率。


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


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

/**
   * 
   * 
   * 乐观锁,按时间戳重入
   * 
   * @Description: 乐观锁,按时间戳重入
   * 
   * @param redPacketId
   * @param userId
   * @return
   * 
   * @return: int
   */
  @Override
  @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
  public int grapRedPacketForVersion(Long redPacketId, Long userId) {
    // 记录开始时间
    long start = System.currentTimeMillis();
    // 无限循环,等待成功或者时间满100毫秒退出
    while (true) {
      // 获取循环当前时间
      long end = System.currentTimeMillis();
      // 当前时间已经超过100毫秒,返回失败
      if (end - start > 100) {
        return FAILED;
      }
      // 获取红包信息,注意version值
      RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
      // 当前小红包库存大于0
      if (redPacket.getStock() > 0) {
        // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
        int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
        // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
        if (update == 0) {
          continue;
        }
        // 生成抢红包信息
        UserRedPacket userRedPacket = new UserRedPacket();
        userRedPacket.setRedPacketId(redPacketId);
        userRedPacket.setUserId(userId);
        userRedPacket.setAmount(redPacket.getUnitAmount());
        userRedPacket.setNote("抢红包 " + redPacketId);
        // 插入抢红包信息
        int result = userRedPacketDao.grapRedPacket(userRedPacket);
        return result;
      } else {
        // 一旦没有库存,则马上返回
        return FAILED;
      }
    }
  }


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

初始化数据后,进行测试


20181009222411982.png

20181009222424763.png


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


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


/**
   * 
   * 
   * @Title: grapRedPacketForVersion
   * 
   * @Description: 乐观锁,按次数重入
   * 
   * @param redPacketId
   * @param userId
   * 
   * @return: int
   */
  @Override
  @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
  public int grapRedPacketForVersion(Long redPacketId, Long userId) {
    for (int i = 0; i < 3; i++) {
      // 获取红包信息,注意version值
      RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
      // 当前小红包库存大于0
      if (redPacket.getStock() > 0) {
        // 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据
        int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
        // 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
        if (update == 0) {
          continue;
        }
        // 生成抢红包信息
        UserRedPacket userRedPacket = new UserRedPacket();
        userRedPacket.setRedPacketId(redPacketId);
        userRedPacket.setUserId(userId);
        userRedPacket.setAmount(redPacket.getUnitAmount());
        userRedPacket.setNote("抢红包 " + redPacketId);
        // 插入抢红包信息
        int result = userRedPacketDao.grapRedPacket(userRedPacket);
        return result;
      } else {
        // 一旦没有库存,则马上返回
        return FAILED;
      }
    }
    return FAILED;
  }


通过 for 循环限定重试 3 次, 3 次过后无论成败都会判定为失败而退出 , 这样就能避免过多的重试导致过多 SQL 被执行的问题,从而保证数据库的性能.

同样的测试步骤,来看下统计结果



20181009224200670.png

2018100922421373.png


3 万次请求,所有红包都被抢到了 , 也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。


还能更好?


现在是使用数据库的情况,有时候并不想使用数据库作为抢红包时刻的数据保存载体,而是选择性能优于数据库的 Redis。 之前接触过了Redis的事务,结合lua来实现抢红包的功能

Redis-09Redis的基础事务

Redis-10Redis的事务回滚

Redis-11使用 watch 命令监控事务

先看下理论知识,下篇博文一起来探讨使用Redis + lua 实现抢红包的功能吧。


代码

https://github.com/yangshangwei/ssm_redpacket

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
6月前
|
NoSQL Java 数据库
优惠券秒杀案例 - CAS、Redis+Lua脚本解决高并发并行
优惠券秒杀案例 - CAS、Redis+Lua脚本解决高并发并行
308 0
|
运维 Java
高并发下Netty4底层bug导致直接内存溢出分析
高并发下Netty4底层bug导致直接内存溢出分析
191 0
|
存储 Java 数据安全/隐私保护
项目实战典型案例15——高并发环境下由于使用全局变量导致数据混乱 高并发环境下对象被大量创建,导致GC并是CPU飙升
项目实战典型案例15——高并发环境下由于使用全局变量导致数据混乱 高并发环境下对象被大量创建,导致GC并是CPU飙升
156 0
项目实战典型案例15——高并发环境下由于使用全局变量导致数据混乱 高并发环境下对象被大量创建,导致GC并是CPU飙升
|
缓存 NoSQL Java
高并发-【抢红包案例】之四:使用Redis+Lua脚本实现抢红包并异步持久化到数据库
高并发-【抢红包案例】之四:使用Redis+Lua脚本实现抢红包并异步持久化到数据库
440 0
|
XML JSON 缓存
lazada获得lazada商品详情 API接口调用成功案例分享 返回数据解析(实时数据,支持高并发请求)
lazada获得lazada商品详情 API接口调用成功案例分享 返回数据解析(实时数据,支持高并发请求)
|
SQL Web App开发 BI
高并发-【抢红包案例】之二:使用悲观锁方式修复红包超发的bug
高并发-【抢红包案例】之二:使用悲观锁方式修复红包超发的bug
103 0
|
6月前
|
消息中间件 Java Linux
2024年最全BATJ真题突击:Java基础+JVM+分布式高并发+网络编程+Linux(1),2024年最新意外的惊喜
2024年最全BATJ真题突击:Java基础+JVM+分布式高并发+网络编程+Linux(1),2024年最新意外的惊喜
|
5月前
|
缓存 NoSQL Java
Java高并发实战:利用线程池和Redis实现高效数据入库
Java高并发实战:利用线程池和Redis实现高效数据入库
483 0
|
3月前
|
监控 算法 Java
企业应用面临高并发等挑战,优化Java后台系统性能至关重要
随着互联网技术的发展,企业应用面临高并发等挑战,优化Java后台系统性能至关重要。本文提供三大技巧:1)优化JVM,如选用合适版本(如OpenJDK 11)、调整参数(如使用G1垃圾收集器)及监控性能;2)优化代码与算法,减少对象创建、合理使用集合及采用高效算法(如快速排序);3)数据库优化,包括索引、查询及分页策略改进,全面提升系统效能。
48 0
|
5月前
|
存储 NoSQL Java
探索Java分布式锁:在高并发环境下的同步访问实现与优化
【6月更文挑战第30天】Java分布式锁在高并发下确保数据一致性,通过Redis的SETNX、ZooKeeper的临时节点、数据库操作等方式实现。优化策略包括锁超时重试、续期、公平性及性能提升,关键在于平衡同步与效率,适应大规模分布式系统的需求。
169 1

热门文章

最新文章

  • 1
    高并发场景下,到底先更新缓存还是先更新数据库?
    66
  • 2
    Java面试题:解释Java NIO与BIO的区别,以及NIO的优势和应用场景。如何在高并发应用中实现NIO?
    74
  • 3
    Java面试题:设计一个线程安全的单例模式,并解释其内存占用和垃圾回收机制;使用生产者消费者模式实现一个并发安全的队列;设计一个支持高并发的分布式锁
    68
  • 4
    Java面试题:如何实现一个线程安全的单例模式,并确保其在高并发环境下的内存管理效率?如何使用CyclicBarrier来实现一个多阶段的数据处理任务,确保所有阶段的数据一致性?
    62
  • 5
    Java面试题:结合建造者模式与内存优化,设计一个可扩展的高性能对象创建框架?利用多线程工具类与并发框架,实现一个高并发的分布式任务调度系统?设计一个高性能的实时事件通知系统
    55
  • 6
    Java面试题:假设你正在开发一个Java后端服务,该服务需要处理高并发的用户请求,并且对内存使用效率有严格的要求,在多线程环境下,如何确保共享资源的线程安全?
    69
  • 7
    在Java中实现高并发的数据访问控制
    42
  • 8
    使用Java构建一个高并发的网络服务
    29
  • 9
    微服务06----Eureka注册中心,微服务的两大服务,订单服务和用户服务,订单服务需要远程调用我们的用,户服务,消费者,如果环境改变,硬编码问题就会随之产生,为了应对高并发,我们可能会部署成一个集
    37
  • 10
    如何设计一个秒杀系统,(高并发高可用分布式集群)
    129