为什么仅靠简单的乐观锁和悲观锁不能处理秒杀系统业务

687 阅读12分钟

cf086de640910ffdb9cd180dafea772e30915729.jpg@1320w_740h.avif

前言

一提到抢火车票这样的高并发业务,大家第一时间想到的肯定就是“乐观锁”和“悲观锁”这两个名词。

不管是科班的朋友在学操作系统、工程实践或实习课的时候,或是外面培训班的老师给你上课的时候,又或是在网上查大佬写的文章的时候,他们都会拿秒杀系统作为引出这两个概念的例子。(当然,如果确实没教过也不能怪你的老师,毕竟“读计算机只能靠自学”这句话不是没有道理的)

如果这个时候我告诉你,你的老师在课上写的那些乐观锁和悲观锁的业务代码,有99%的概率没有办法真正解决高并发秒杀业务,特别是包含超卖问题的业务,你会感到惊讶吗?

这篇文章当中我会通过模拟现实中读取和修改数据库中商品库存的业务,简单地向大家展示为什么不能只通过简单的乐观锁或悲观锁(特别是通过简单的数据库锁)完成秒杀业务。

示例代码用的是大家最熟悉的Java语言,其他语言同理,我写的代码的可读性挺强的,你看了就知道了。

e7233a3472f867c76b9b9e21de87cbfb30915729.png@1256w_478h_!web-article-pic.avif

什么是乐观锁和悲观锁

谈谈悲观锁和乐观锁的定义。

  • 顾名思义,悲观锁指的就是在并发的环境下,我认为别人必定会对目标资源做修改,因此会在拿到资源时对资源上锁,直到我使用完之后才把资源释放给其他人使用。
  • 同理,乐观锁指的是在并发的情况下,我不需要管别人有没有对目标资源做修改,只是在需要修改资源的时刻,检测资源是否是先前没修改过的状态,如果是,则修改,否则放弃修改,如有需要则从头开始流程以便重试。换句话说,乐观锁其实并不是物理层面上的锁,而是一种逻辑锁。

通过二者的定义可以看出,乐观锁更加适合修改量少、读取量大的场景,性能较高,悲观锁则反之。

使用数据库悲观锁解决超卖问题

我们先来看在业务当中,使用悲观锁售卖商品的情况。如果你的老师为你展示过秒杀系统中悲观锁的业务代码,那么他的代码应该和我接下来要展示的内容差不多。

我定义了一张product数据表,语句如下:

create table product
(
    id      int auto_increment comment '商品id'
        primary key,
    stock   int null comment '商品库存',
    version int null comment '数据版本号'
);

这里的版本号是拿给后面做乐观锁用的,我们先不管他。顺带增加一条(1, 90, 0)的数据记录。

然后我们在springboot项目当中,实现查询Mapper:

@Mapper
public interface SellPessimisticMapper {

    @Update("UPDATE product SET stock = stock - 1 WHERE id = #{id} AND stock > 0")
    int sellProduct(int id);

    @Select("select stock from product where id = #{id} for update")
    Product getProductForUpdate(int id);
}

可以看到,我们在查询商品项目时用了for update关键字,这个关键字可以对查出来的数据使用行锁,直到接受了修改才被释放,也就是说这是数据库层面上的悲观锁。

接下来编写服务层代码,我只展示接口实现类:

@Service
public class SellPessimisticServiceImpl implements SellPessimisticService {

    @Resource
    private SellPessimisticMapper sellPessimisticMapper;

    @Override
    @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
    public Result<Integer> sellProduct(int id) {
        Product product = sellPessimisticMapper.getProductForUpdate(id);
        if (product.getStock() > 0) {
            if (sellPessimisticMapper.sellProduct(id) > 0) {
                return Result.ok(product.getStock() - 1);
            } else {
                return Result.fail("发生了错误,请稍后再试");
            }
        } else {
            return Result.fail("库存不足");
        }
    }
}

这里的Result是我自己写的响应类,有code、data、msg三个字段,分别指代判定是否成功的响应状态码、返回的数据和文本提示信息。

我定义了一个sellProduct方法用于售卖商品,它首先获得了商品数据并上行锁,然后判断商品库存是否大于0,大于则售出并返回剩余库存,否则提示库存不足。

方法前记得加一个@Transactional注解,用以控制数据库事务。

接下来我们编写测试类,代码如下:

@SpringBootTest
class SellPessimisticTests {

    @Resource
    private SellPessimisticService sellPessimisticService;

    // 目的:创建100个线程,模拟100个用户抢购商品的场景,观察悲观锁导致的用户抢购等待时间过长的情况
    @Test
    void contextLoads() {
        ExecutorService executorService = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 100; i++) {
            int finalI = i;
            executorService.submit(() -> {
                // 设置起始时间
                long startTime = System.currentTimeMillis();

                String name = "线程" + finalI;
                Result<Integer> result = sellPessimisticService.sellProduct(1);

                // 设置结束时间
                long endTime = System.currentTimeMillis();

                if (result.getCode() == 200) {
                    System.out.println(name + "成功购买一件商品,剩余数量为" + result.getData() + ",耗时" + (endTime - startTime) + "ms");
                } else {
                    System.out.println(name + "购买失败,商品卖完了" + ",耗时" + (endTime - startTime) + "ms");
                }
            });
        }
        // 等待所有任务执行完成
        while (true) {
        }
    }
}

首先我创建了一个可以容纳100个线程的线程池,然后我又分别创建了100个线程,测试售卖的成功与否,并输出从发起购买请求到请求结束的时间。

可以看到测试类的输出结果如下:

image.png image.png

可以看到,由于加了悲观锁,等到前一个用户的购买处理结束才会轮到下一个用户,因此不同用户的请求所耗费的时间是依次增长的。

在100个线程的情况下,甚至连抢夺商品失败的用户也会硬生生等上好几秒钟,换到超卖高并发的状态下用户的体验会非常差。毕竟硬生生等着手机屏幕里那个圈足足转了三分钟,最后等到的却是没抢到商品的提示,换做谁多多少少也会很生气吧。

用数据库乐观锁解决超卖问题

为了解决悲观锁性能差的问题,我们这个时候需要引入乐观锁。

我们创建乐观锁情况下使用的Mapper:

@Mapper
public interface SellOptimisticMapper {

    @Update("update product set stock = stock - 1, version = version + 1 where id = #{id} and version = #{version}")
    int sellProduct(int id, int version);
    
    @Select("select * from product where id = #{id}")
    Product getProduct(int id);
}

这里我们不再使用行锁,而是在update时对version字段进行判断,如果同获取数据时version字段的值一致,则对库存量和version都做修改。

修改version的方法有很多,你可以直接在原数字上加一,或者设置为随机数,当然也可以设置成当前Unix时间戳。

然后创建乐观锁服务层:

@Service
public class SellOptimisticServiceImpl implements SellOptimisticService {

    @Resource
    private SellOptimisticMapper sellOptimisticMapper;

    @Override
    public Result<Integer> sellProduct(int id) {
        Product product = sellOptimisticMapper.getProduct(id);
        if (product.getStock() > 0) {
            if (sellOptimisticMapper.sellProduct(id, product.getVersion()) > 0) {
                return Result.restResult(product.getStock() - 1, 1, "购买成功");
            } else {
                return Result.restResult(null, 2, "当前人数过多,请稍后再试");
            }
        }
        return Result.restResult(null, 0, "库存不足");
    }
} 

这里分为三种情况,code为0、1和2分别对应卖完了、购买成功和version不一致的情况。

现在我们编写测试类:

@SpringBootTest
class SellOptimisticTests {

    @Resource
    private SellOptimisticService sellOptimisticService;

    // 目的:创建10个线程,模拟10个用户抢购商品的场景,观察乐观锁导致的同批次用户抢购失败的情况
    @Test
    void contextLoads() {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            executorService.submit(() -> {
                String name = "线程" + finalI;
                while (true) {
                    // 设置起始时间
                    long startTime = System.currentTimeMillis();

                    Result<Integer> result = sellOptimisticService.sellProduct(1);

                    // 设置结束时间
                    long endTime = System.currentTimeMillis();

                    if (result.getCode() == 1) {
                        System.out.println(name + "成功购买一件商品,剩余数量为" + result.getData() + ",耗时" + (endTime - startTime) + "ms");
                    } else if (result.getCode() == 2) {
                        System.out.println(name + "购买失败,没有抢得资源" + ",耗时" + (endTime - startTime) + "ms");
                    } else {
                        System.out.println(name + "购买失败,商品已售罄" + ",耗时" + (endTime - startTime) + "ms");
                        break;
                    }
                }
            });
        }
        // 等待所有任务执行完成
        while (true) {
        }
    }
} 

我创建了10个线程,不停地循环抢购商品,然后对购买结果进行判断并输出耗时。

执行结果如下:

image.png image.png

可以发现,悲观锁导致的等待时间长、性能差的问题已经解决了。

但值得一提的是,这个逻辑锁行为还是基于数据库查询执行的,在高并发的情况下数据库压力会非常大,这也不符合我们的业务需求。

此外,如果有一批用户同时请求资源,这个时候只有一个人能够买到商品,剩下的人都会被立刻阻挡在外,即便此时还有剩余库存。此时用户需要反复点击按钮抢购商品,这同样也会导致用户的体验极差。

真正有效的解决方案——令牌桶算法(配合乐观锁使用)

由此可以看出,实际上秒杀系统的高并发问题并不是仅靠使用乐观锁和悲观锁概念就能轻松解决的。我们还是要根据实际业务需求,对症下药,研究出不同的解决方案。

这里我只举出一个我接这一类项目时比较常用的,同样也是市面上大部分厂商都比较喜欢用的一个解决方法——令牌桶算法。

令牌桶算法(Token Bucket)是一种网络流量整形和流量控制算法。它的主要思想是系统以恒定的速度向桶中添加令牌,而请求则需要从桶中获取令牌才能处理。如果桶中没有令牌,那么请求将被阻塞或拒绝。

在Java这边,Google提供了一个三方依赖工具库叫做Guava,其中就有实现了令牌桶功能的限流工具类RateLimiter。

我们重新对乐观锁服务类做修改:

@Service
public class SellOptimisticWithTokenServiceImpl implements SellOptimisticWithTokenService {
    @Resource
    private SellOptimisticMapper sellOptimisticMapper;

    // 每秒放行5个请求
    private final RateLimiter rateLimiter = RateLimiter.create(5);

    @Override
    public Result<Integer> sellProduct(int id) {
        // 非阻塞式获取令牌,即尝试获取令牌,获取不到则立即返回
        if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
            return Result.restResult(null, 3, "当前人数过多,请稍后再试(令牌不足)");
        }
        // 阻塞式获取令牌,即尝试获取令牌,获取不到则等待直到获取到令牌
//        rateLimiter.acquire();
        Product product = sellOptimisticMapper.getProduct(id);
        if (product.getStock() > 0) {
            if (sellOptimisticMapper.sellProduct(id, product.getVersion()) > 0) {
                return Result.restResult(product.getStock() - 1, 1, "购买成功");
            } else {
                return Result.restResult(null, 2, "当前人数过多,请稍后再试(版本号不一致)");
            }
        }
        return Result.restResult(null, 0, "库存不足");
    }
} 

我们创建了一个令牌桶,每秒向其中添加5个令牌。

RateLimiter为我们提供了非阻塞式获取令牌和阻塞式获取令牌的方法,在获取不到令牌时,前者会直接阻止后续的操作,后者会等待到令牌补充并轮到对应线程操作的时候再放行。

我们先测试非阻塞式的效果,这里我们多返回一个code为3的结果,用以指代令牌不足的情况。

现在我们修改测试类:

@SpringBootTest
class SellOptimisticWithTokenTest {
    @Resource
    private SellOptimisticWithTokenService sellOptimisticWithTokenService;

    // 目的:创建10个线程,模拟10个用户抢购商品的场景,观察使用令牌桶优化之后的区别
    @Test
    void contextLoads() {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            executorService.submit(() -> {
                String name = "线程" + finalI;
                while (true) {
                    // 设置起始时间
                    long startTime = System.currentTimeMillis();

                    Result<Integer> result = sellOptimisticWithTokenService.sellProduct(1);

                    // 设置结束时间
                    long endTime = System.currentTimeMillis();

                    if (result.getCode() == 1) {
                        System.out.println(name + "成功购买一件商品,剩余数量为" + result.getData() + ",耗时" + (endTime - startTime) + "ms");
                    } else if (result.getCode() == 2) {
                        System.out.println(name + "购买失败,没有抢得资源" + ",耗时" + (endTime - startTime) + "ms");
                    } else if (result.getCode() == 3) {
                        System.out.println(name + "购买失败,令牌不足" + ",耗时" + (endTime - startTime) + "ms");
                    } else {
                        System.out.println(name + "购买失败,商品已售罄" + ",耗时" + (endTime - startTime) + "ms");
                        break;
                    }
                }
            });
        }
        // 等待所有任务执行完成
        while (true) {
        }
    }
} 

同样是多增加一个令牌不足的判定情况,我们看看输出结果是什么:

image.png

控制台瞬间刷出一大片令牌不足的提示。

然而注释掉令牌不足的输出语句,从显示内容可以看出,此时商品确实被卖出去了。

image.png image.png

此外还可以看出,由于一开始启动时同时请求出令牌桶里的令牌,会导致出现了乐观锁阻止商品购买的情况,但后面就几乎不会出现这种情况了。

综上,使用令牌桶的非阻塞式获取方法,会同乐观锁一样,把高并发请求大量阻止在外,但这个方法并没有经过数据库,因此大幅减轻了数据库的压力。然而实际上又由于把大量的请求阻止在外,只有极少数的请求可以通过,实际上并没有解决用户体验差的问题。

我们把服务层中非阻塞式的方法注释掉,换成阻塞式方法试试看。

image.png image.png

我们会发现一个很有趣的现象:

除了与先前一致的后面也基本没有乐观锁阻止更新的情况外,现在大部分的用户都可以按照顺序抢到商品,并且到达商品的秒杀周期的中后期的时候,等待时间近乎一致。

这意味着我们解决了用户多次请求失败导致使用体验较差的问题,一开始设置的每秒钟5个令牌的数量比较符合数据库处理性能以及高并发期间用户流量情况。

但这又会把先前等待时间较长的问题带回来,但再怎么说还是会比先前只用悲观锁的情况要好得多。因此需要你根据实际业务决策出每秒钟的放行数,以及结合实际业务,实现在商品售罄时及时向用户返回库存不足消息而不是等待令牌的代码。

总结

最后在文末简单总结一下我的观点。

通过这一次的实验探究我们可以发现,实际业务还是要和求职死记硬背的八股文区分开。

超卖秒杀系统确实是教学乐观锁和悲观锁的优秀例子,但大伙为了图方便,常常都会把锁写在数据库层面上,这不符合高并发业务的处理需求,不仅没讨好用户,服务器的数据库也难逃一死。

实际业务和用户的需求往往是变化多样的,并没有什么万精油一说,千万不要因为自己貌似学会了什么看似高大上的名词就去走弯路,学以致用≠学了乱用