大家好,我是洋子
最近在做单接口的性能测试比较多,在压测过程发现了一个比较有意思的问题,拿出来和大家分享一下
背景是这样的,最近在搞线上的抽奖活动,压测的对象是一个抽奖接口,主要的逻辑见程序的流程图
这个抽奖的接口逻辑是先通过检查Redis里面存入的已发放奖品数量
查出已发放奖品数量后,与活动配置当中的奖品库存进行对比
若无库存,此时已发放的奖品数量大于了活动预先配置的奖品库存,那么返回库存为空的信息
若还有库存,在Redis里面新增本次中奖的用户信息,设置Redis过期时间,接着进入后续发奖品的逻辑(写DB,修改发送状态等)
在无并发(同一时间内只有一个用户请求)的场景下,这样处理并没有问题,但是在压测当中,有并发请求的场景下,我发现DB里面写入超出库存数量的记录,换句话说上面通过Redis检查库存,拦截多发奖的逻辑存在Bug,没有正常拦截
通过Review代码,我发现这段检测库存的逻辑Redis 的zCard查询,再zAdd插入,为非原子性操作,zCard与zAdd为串行执行,在并发场景下,zCard可能查出的库存是相同的,如活动配置的库存为6,有10个并发用户同时查询出当前已抽奖品数量为5,因为总库存6>已抽奖品数5,当前还有剩余库存,则正常为这10个并发用户发送奖品
// 查Redis:当前活动已抽出的奖品数量
$strKey = sprintf(PrizeStockKey, PrizeInfo_Id, $strDayTime);
$intStock = $daoRedis->zCard($strKey);
// 检查当前库存,是否超过活动预先配置的库存
if($intStock >= $arrPrizeInfo['stock']) {
Log::warning("the stock empty");
return $Output;
}
$ret = $daoRedis->zAdd($strKey, $intUserId, $intTime);
//后续逻辑:写DB发送奖品,此处省略
超发奖品这样的逻辑显然是不符合预期的,那我们该如何修改呢,先zAdd再zCard,行不行呢,答案也是不可以,因为先zAdd可能会导致所有用户均无法进行奖品发送
举个例子,总库存为6,此时并发10个用户进行zAdd,再进行zCard 查询为10,超过总库存,此时程序认为奖品已经发完,无法正常发奖
// 查Redis:当前活动已抽出的奖品数量
$strKey = sprintf(PrizeStockKey, PrizeInfo_Id, $strDayTime);
//先zAdd,再查询zCard
$ret = $daoRedis->zAdd($strKey, $intUserId, $intTime);
$intStock = $daoRedis->zCard($strKey);
// 检查当前库存,是否超过活动预先配置的库存
if($intStock > $arrPrizeInfo['stock']) {
Log::warning("the stock empty");
return $Output;
}
//后续逻辑:写DB发送奖品,此处省略
产生这样的现象,归根结底还是以上逻辑Redis都是非原子操作,我们先了解一下什么是原子操作
原子操作是指在计算机科学中的一种操作方式,它被设计成在执行期间不可中断的单个操作。原子操作要么完全执行,要么完全不执行,不会出现中间或部分执行的情况。原子操作通常用于多线程或并发编程中,用于确保共享资源的一致性和并发访问的正确性
原子性:原子操作是不可分割的单个操作,要么全部执行成功,要么全部不执行。没有其他线程能够观察到原子操作的中间状态
独立性:原子操作是独立于其他操作的,不受其他线程的干扰或影响。原子操作的执行不会受到并发环境的影响。
可以用于实现对共享数据的互斥访问,以避免竞态条件(race condition)的发生。竞态条件是指多个线程对同一共享资源进行并发访问时可能导致的不确定或不正确的结果
原子读取(atomic read)、原子写入(atomic write)、原子递增(atomic increment)、原子比较并交换(atomic compare-and-swap)等。这些操作通常由硬件或操作系统提供支持,以确保其执行的原子性。
为了修复此问题,我们将zCard和zAdd改成incrBy即可解决上面的问题,因为Redis的incrBy是原子操作,在并发场景也不会出现因并发访问而导致的数据不一致或竞态条件问题
//写redis:当前奖品抽出数量+1
$intStock = $daoRedis->incrBy($strKey, 1);
// 检查当前库存,是否超过活动预先配置的库存
if($intStock >= $arrPrizeInfo['stock']) {
Log::warning("the stock empty");
return $Output;
}
//后续逻辑:写DB发送奖品,此处省略
完事后,我仔细想想,还好通过这次压测,发现了这个问题,要是活动当中本来发1w现金,最后发成了100w,那我不直接被开了