掘金 后端 ( ) • 2024-04-03 14:58

介绍

具体的业务业务场景:后台导入第三方券若干,当用户触发某个行为时(比如注册或者抽奖),从里面取一个分配给客户,直到用完。

期望目标:不能重复分配,不能超量分配(超卖),并发时有好的性能表现。

本文介绍一种使用 MySQL 的 skip locked 来实现并发查询待销费记录时的顺序消费、防重复消费、避免锁等待等问题。

数据结构和伪代码

coupon 表

id code member_id 1 0001 0 2 0002 0 3 0003 0 4 0004 0

基本业务伪代码

function assignCoupon($memberId) {
    try {
        $db->begin();
        // 1. 取一条未分配的
        $unusedCoupon = $db->fetchRow("select * from coupon where member_id = 0"); 
        // 2. 更新分配状态
        $unusedCoupon->member_id = $memberId;
        $unusedCoupon->save();
        // 3. 做些其他事情
        $db->commit();
    } catch (\Exception $e) {
        $db->rollback();
        throw $e;
    }
}

分析和解决

虽然开启了事务,但第一步取数据时,因为 MySQL 的可重复读,可能会导致两个并发进程获取到同一条未使用记录,导致重复分配(且会覆盖掉一个进程的写操作)。

可以使用 MySQL 的 select ... for update 来加排他锁,锁定选定行,来确保第二个进程不重复获取同一条数据。但这样会导致第二个进程的读取操作处于等待状态,一直等第一个进程提交事务或者回滚事务而释放锁。这会导致所有的操作从并发变成顺序执行,大大降低并发时的性能。

这种场景其实期望的是,如果第一个进程在消费第一个记录,第二个进程不用等它的执行结果(成功或者失败),完全可以去取下一条未分配的记录,各自消费各自的记录。如果当前所有记录都被锁定,则当做没有可使用数据直接返回。最终若有记录消费失败会被释放,可以由其他进程再次消费。

这可以借助 MySQL 的 select ... for update skip locked 来跳过锁定行。最终代码被改造成如下:

function assignCoupon($memberId) {
    try {
        $db->begin();
        // 1. 取一条未分配且未被锁定的数据
        $unusedCoupon = $db->fetchRow("select * from coupon where member_id = 0 for update skip locked"); 
        // 2. 更新分配状态
        $unusedCoupon->member_id = $memberId;
        $unusedCoupon->save();
        // 3. 做些其他事情
        $db->commit();
    } catch (\Exception $e) {
        $db->rollback();
        throw $e;
    }
}

需要注意的是,skip locked 是在 MySQL 8.0 被引入的。

对要读取的数据加锁,很多技术方案都会推荐加分布式锁。我对分布式的理解,能根据被锁定的对象设置单独的锁(就是key),有一个公共的地方来存储锁,所有使用者都能访问到,有锁定时间(或者过期机制)。

从这点来讲,MySQL 的 for update 足够应对这个场景,相比分布式锁,不用引入其他的资源,不用处理锁的自动释放(会随事务的提交或者回滚自动释放)。当然这点还是具体针对业务或者数据量来看。

其他常规防库存防超卖的几种方案,兜底的保护措施,等日后再写新文章列举。