场景:

物品W现在库存剩余1个, 用户P1,P2同时购买.则只有1人能购买成功。(前提是不允许超卖)
秒杀也是类似的情况, 只有1件商品,N个用户同时抢购,只有1人能抢到…

常见的实现方案有以下几种:

1、代码同步,如:使用 synchronized、lock等同步方法。
2、不查询,直接进行更新 update table set surplus (surplus - buyQuantity) where id = xxx and (surplus - buyQuantity) > 0;
3、使用CAS,update table set surplus = aa where id = xxx and version = y;
4、使用数据库锁 select xxx for update;
5、使用Redis;

第一种方案

代码如下:

public synchronized void buy(String productName, Integer buyQuantity) {
 // 其他校验...
 // 校验剩余数量
 Product product  = 从数据库查询出记录;
 if (product.getSurplus < buyQuantity) {
  return "库存不足";
 }

 // set新的剩余数量
 product.setSurplus(product.getSurplus() - quantity);
 // 更新数据库
 update(product);
 // 记录日志...
 // 其他业务...
}

在方法声明加上 synchronized 关键字,实现同步,这样2个用户同时购买,到buy方法时候同步执行,第2个用户执行的时候,会库存不足。

先说下这个方案的前提配置:

1、使用 Spring 声明式事务管理
2、事务传播机制使用默认的(PROPAGATION_REQUIRED)
3、项目分层为Controller-Service-Dao 3层, 事务管理在Service层

这个方案不可行,主要是因为以下几点:

1、synchronized 作用范围是单个Jvm实例,如果做了集群,分布式等,就没用了。
2、synchronized 是作用在对象实例上的,如果不是单例,则多个实例间不会同步(这个一般用spring管理bean,默认就是单例)。
3、单个Jvm时,synchronized 也不能保证多个数据库事务的隔离性。这与代码中的事务传播级别,数据库的事务隔离级别,加锁时机等相关。

先说隔离级别,常用的是 Read Committed(读已提交) 和 Repeatable Read(可重复读) ,另外2种不常用就不说了

RR级别:
RR: Mysql默认的是RR,事务开启后,不会读取到其他事务提交的数据。
根据前面的前提,我们知道在buy方法时会开启事务。
假设现在有线程T1,T2同时执行buy方法.假设T1先执行,T2等待。
Spring的事务开启和提交等是通过Aop(代理)实现的,所以执行buy方法前,就会开启事务。
这时候T1,T2是两个事务,当T1执行完后,T2执行,读取不到T1提交的数据,所以会出问题。

RC级别:
RC: 事务开启后,可以读取到其他事务提交的数据。

看起来这个级别可以解决上面的问题.T2执行时,可以读取到T1提交的结果。
但是问题是,T2执行的时候, 确定T1的事务提交了吗?

事务和锁的流程如下:

1、开启事务(Aop)
2、加锁(进入synchronized方法)
3、释放锁(退出synchronized方法)
4、提交事务(Aop)

可以看出是先释放锁,再提交事务.所以T2执行查询,可能还是未读到T1提交的数据,还会出问题。

发现主要矛盾是事务开启和提交的时机与加锁解锁时机不一致。

在事务开启前加锁,事务提交后解锁。

确实是可以,这相当于事务串行化.抛开性能不谈,来谈谈怎么实现。

如果使用默认的事务传播机制,那么要保证事务开启前加锁,事务提交后解锁,就需要把加锁,解锁放在Controller层。

这样就有个潜在问题,所有操作库存的方法,都要加锁,而且要是同一把锁,写起来挺累的。

而且这样还是不能跨Jvm。

将查询库存,扣减库存这2步操作,单独提取个方法,单独使用事务,并且事务隔离级别设置为RC

这个其实和上面异曲同工,最终都是讲加解锁放在了事务开启提交外层。

比较而言优点是入口少了。 Controller不用处理。

缺点除了上面的不能跨jvm,还有就是 单独的这个方法,需要放到另外的service类中。

因为使用Spring,同一个Bean的内部方法调用,是不会被再次代理的,所以配置的单独事务等需要放到另外的Service Bean 中。

第二种方案

我们不查询,直接更新不就行啦。

代码如下:

public synchronized void buy(String productName, Integer buyQuantity) {
 // 其他校验...
 int 影响行数 = update table set surplus = (surplus - buyQuantity) where id = 1 ;
 if (result < 0) {
  return "库存不足";
 }
 // 记录日志...
 // 其他业务...
}

测试后发现库存变成-1了, 继续完善下

public synchronized void buy(String productName, Integer buyQuantity) {
 // 其他校验...
 int 影响行数 = update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
 if (result < 0) {
  return "库存不足";
 }
 // 记录日志...
 // 其他业务...
}

测试后,功能OK;

这样确实可以实现,不过有一些其他问题:

不具备通用性,例如add操作
库存操作一般要记录操作前后的数量等,这样没法记录

第三种方案

CAS全写:Compare And Swap, 比较与交换,是一种无锁算法。

当前的这个线程想改这个值,我期望你是0,你就不能是1;如果是1,那就说明我这个值不对,然后想把你变成1。大概就是:原来这个值是变为3了,我这个线程想修改这个值的时候我一定期望你现在是3,是3我才改,如果在我修改的过程你变4了,说明就有另外一个线程修改过该值,那我cas就再重新试一下,再试的时候,我希望你的这个值是4,在修改的时候期望值是4,没有其它线程修改该值,那好我给你改成5,这样就是cas操作。

int 影响行数 = update table set surplus = newQuantity where id = 1 and surplus = oldQuantity ;

这样,线程T1执行完后,线程T2去更新,影响行数=0,则说明数据被更新, 重新查询判断执行。代码如下:

public void buy(String productName, Integer buyQuantity) {
 // 其他校验...
 Product product = getByDB(productName);
 int 影响行数 = update table set surplus = (surplus - buyQuantity) where id = 1 and surplus = 查询的剩余数量 ;
 while (result == 0) {
  product = getByDB(productName);
  if (查询的剩余数量 > buyQuantity) {
   影响行数 = update table set surplus = (surplus - buyQuantity) where id = 1 and surplus = 查询的剩余数量 ;
  } else {
   return "库存不足";
  }
 }
 // 记录日志...
 // 其他业务...
}

上面代码中的getByDB方法,必须单独事务(注意同一个bean内单独事务不生效哦),而且数据库的事务隔离级别必须是RC,

否则上面的代码就会是死循环了。

上面的方案,可能会出现一个CAS中经典问题. ABA的问题。

ABA问题:

假如原来值是0,A线程拿到0以后进行修改,
这时候B线程进来拿到0修改为1,又修改为0,A修改完改完要和之前的进行对比,发现之前的还是0,则修改成功。

一般的设计中CAS会使用version来控制。

update t set surplus = 90 ,version = version+1 where id = x and version = oldVersion ;

这样,每次更新version在原基础上+1,就可以了。

使用CAS要注意几点:

失败重试次数,是否需要限制
失败重试对用户是透明的(就是需要用户知道)

第四种方案

方案三是CAS,是乐观锁的实现, 而 select for udpate 则是悲观锁. 在查询数据的时候,就将数据锁住。

代码如下:

public void buy(String productName, Integer buyQuantity) {
 // 其他校验...
 Product product = select * from table where name = productName for update;
 if (查询的剩余数量 > buyQuantity) {
  影响行数 = update table set surplus = (surplus - buyQuantity) where name = productName ;
 } else {
  return "库存不足";
 }

 // 记录日志...
 // 其他业务...
}

线程T1 进行sub , 查询库存剩余 100

线程T2 进行sub , 这时候,线程T1事务还未提交,线程T2阻塞,直到线程T1事务提交或回滚才能查询出结果。

所以线程T2查询出的一定是最新的数据.相当于事务串行化了,就解决了数据一致性问题。

对于select for update,需要注意的有2点。

1、统一入口:所有库存操作都需要统一使用 select for update ,这样才会阻塞, 如果另外一个方法还是普通的select, 是不会被阻塞的。
2、加锁顺序:如果有多个锁,那么加锁顺序要一致,否则会出现死锁。

第五种方案

基于Redis的decr实现,我们都知道Redis是单线程操作。

public boolean getTicket(String userId, String discountCode) {
        boolean temp = false;
        // 用户优惠码领取key
        String userKey = discountCode + "_receive_" + userId;
        // 判断key是否存在,key里面数量是否大于0
        if (!hasKey(discountCode) || Long.parseLong(get(discountCode)) <= 0L) {
            return temp;
        }
        try {
            // 1、根据用户key查询map里面是否存在
            String exists = getMapField(discountCode + "_received", userKey);
            if (!StringUtils.isBlank(exists)) {
                LOGGER.info("优惠码您已领取过,无法再次领取! {}", userId);
                return temp;
            }
            // 2、先使用redis的decr可以实现原子性的递增递减操作控制优惠码不超送,
            Long success = decr(discountCode);
            // 3、先减库存后发码(减库存后返回的现有库存数量大于等于0说明本次抢码成功,再进行发送优惠码,否则库存已经空了就不进行发送优惠码)
            if (success >= 0L) {
                // 3、再进行优惠码分发(可进行MQ进行发放优惠码,如果减库存成功,发放失败,进行发放补偿性操作)
                mapUpdate(discountCode + "_received", userKey, "received");
                temp = true;
                LOGGER.info("领取成功! {}", userId);
                return temp;
            }
        } catch (Exception e) {
            LOGGER.error("领取失败:{}", e);
        }
        return temp;
    }
@RequestMapping("/test")
@ResponseBody
public ApiResult test(int id) {
    try {
        // 设置库存数量
        // redisUtil.setCacheObject("ticket1", 10);
        redisUtil.getTicket(id + "", "ticket1");
    } catch (Exception e) {
        LOGGER.error("test:{}", e);
    }
    return null;
}

总结

方案1: synchronized等jvm内部锁不适合用来保证数据库数据一致性,不能跨Jvm。
方案2: 不具备通用性,不能记录操作前后日志。
方案3: 推荐使用.但是如果数据竞争激烈,则自动重试次数会急剧上升,需要注意。
方案4: 推荐使用.最简单的方案,但是如果事务过大,会有性能问题.操作不当,会有死锁问题。
方案5: 和方案1类似,只是能跨Jvm。

来源:传送