第1节 分布式锁问题背景

59 阅读3分钟

任何解决方案的引入,都是为了解决我们所遇到的业务问题。分布式锁可以说是解决各种业务问题的一把利刃,本篇笔记将从具体的业务场景开始,一步步的来说分布式锁的使用。

  • 拿秒杀扣除库存的业务场景来说明,比如秒杀百事可乐,业务方提供的秒杀库存只有 100 件商品,那么如何防止超卖
  • 超卖就是你计划卖 100 件商品,结果你卖出去了 101 件。这多出来的 1 件商品对于业务方来说就是资损 image.png

1. 超卖并发请求模型

掘金-第 10 页.drawio (2).png


2. 基于内存数据模拟

  • 商品对象
public class Goods implements Serializable {
    //商品总库存
    private Integer count = 5000;

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}
  • service 层代码
@Service
public class GoodsServiceImpl implements GoodsService {

    private static final Logger log = LoggerFactory.getLogger(GoodsService.class);

    private Goods goods = new Goods();

    public void deductStock() {
        goods.setCount(goods.getCount() - 1);
        log.info("商品库存余量 :{}", goods.getCount());
    }
}    
  • 请求入口
@RestController
@RequestMapping("/lock")
public class GoodsController {

    @Resource
    private GoodsService goodsService;

    @GetMapping("/issue")
    public Object deduct() {
        goodsService.deductStock();
        return "success";
    }
}

2.1 测试结果

20 秒内启动 100 个线程分别循环 51 次 image.png

并发请求后导致商品超卖的情况,库存被扣减成负数: image.png

2.2 解决方案

  • 面对单机应用,此时我们能想到的最快的解决方案就是 JDK 的同步工具 synchronizedReentrantLock
public void deductStock() {
    synchronized (GoodsServiceImpl.class) {
        goods.setCount(goods.getCount() - 1);
        log.info("商品库存余量 :{}", goods.getCount());
    }
}

3. 基于mysql数据来模拟

  • 在基于内存数据模拟时,我们使用了 synchronizedReentrantLock ,那么在使用 mysql 后这种方式还能解决问题吗 ???

使用 spring boot + tk.mybatis 框架完成这段代码

image.png

  • 商品对象
package com.example.distributed.lock.seckill.bean;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.io.Serializable;

@Getter
@Setter
@Table(name = "order.goods")
public class Goods implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 商品编码
    @Column(name = "code")
    private String code;

    //商品总库存
    @Column(name = "count")
    private Integer count;
}
  • service 层代码
@Service
public class GoodsServiceImpl implements GoodsService {

    private static final Logger log = LoggerFactory.getLogger(GoodsService.class);
    
    @Resource
    private GoodsMapper goodsMapper;

    public void deduct() {
        Example query = Example.builder(Goods.class).build();
        query.createCriteria().andEqualTo("code", "1001");

        Goods goods = goodsMapper.selectOneByExample(query);
        if (null != goods && goods.getCount() > 0) {
            int count = goods.getCount() - 1;
            log.info("商品库存余量 :{}", count);

            goods.setCount(count);
            goodsMapper.updateByPrimaryKeySelective(goods);
        }
    }
}
  • dao 层代码
import tk.mybatis.mapper.common.Mapper;
import tk.mybatis.mapper.common.MySqlMapper;

public interface SqlMapper<T> extends Mapper<T>, MySqlMapper<T> {
}
package com.example.distributed.lock.seckill.repository.mybatis;

import com.example.distributed.lock.seckill.bean.Goods;
import com.example.distributed.lock.seckill.repository.SqlMapper;

public interface GoodsMapper extends SqlMapper<Goods> {
}

注意 :tk.mybatis 中你自己定义的 SqlMapper 路径不能和 GoodsMapper 在同一目录下,否则启动会报错!!!GoodsMapper 所在的目录就是 @MapperScan 的扫描路径

@MapperScan(basePackages = {"com.example.distributed.lock.seckill.repository.mybatis"})
  • 请求入口
@RestController
@RequestMapping("/lock")
public class GoodsController {

    @Resource
    private GoodsService goodsService;

    @GetMapping("/issue")
    public Object deduct() {
        goodsService.deduct();
        return "success";
    }
}

3.1 测试结果

3.1.1 当业务方法没有开启事务

  • 数据库里面有库存商品 100 个 image.png
  • 立即启动 10 个线程分别循环 10 次
public void deduct() {
    Example query = Example.builder(Goods.class).build();
    query.createCriteria().andEqualTo("code", "1001");

    synchronized (GoodsServiceImpl.class) {
        Goods goods = goodsMapper.selectOneByExample(query);
        if (null != goods && goods.getCount() > 0) {
            int count = goods.getCount() - 1;
            log.info("商品库存余量 :{}", count);

            goods.setCount(count);
            goodsMapper.updateByPrimaryKeySelective(goods);
        }
    }
}

image.png image.png

结论:未开启事务,未发生多扣除商品的情况,synchronized 方案生效

3.1.2 当业务方法开启事务

  • 数据库里面有库存商品 1000 个
  • 20秒内启动 100 个线程分别循环 10 次

image.png

结论:开启事务,使用 synchronized ,发现有重复更新库存数量的现象

4. 总结

通过上面两个实验发现,使用同步工具 synchronized 或 ReentrantLock无法根本解决秒杀业务场景的重复扣减库存的问题!原因如下:

  • MySQL的事务会导致synchronized 失效
  • 集群部署也会导致synchronized 失效