介绍
具体的业务业务场景:后台导入第三方券若干,当用户触发某个行为时(比如注册或者抽奖),从里面取一个分配给客户,直到用完。
期望目标:不能重复分配,不能超量分配(超卖),并发时有好的性能表现。
本文介绍一种使用 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
足够应对这个场景,相比分布式锁,不用引入其他的资源,不用处理锁的自动释放(会随事务的提交或者回滚自动释放)。当然这点还是具体针对业务或者数据量来看。
其他常规防库存防超卖的几种方案,兜底的保护措施,等日后再写新文章列举。