秒杀会产生一个瞬间的高并发,使用数据库会增加数据库的访问压力,也会降低访问速度,所以我们应该使用缓存,来降低数据库的访问压力;
可以看出这里的操作和原来的下单是不一样的:产生的秒杀预订单不会马上写入数据库,会先写入缓存,等用户支付成功时,修改状态,写入数据库。
假设num
是存储在数据库中的字段,保存了被秒杀产品的剩余数量。
if($num > 0){
//用户抢购成功,记录用户信息
$num--;
}
假设在一个并发量较高的场景,数据库中num
的值为1时,可能同时会有多个进程读取到num
为1,程序判断符合条件,抢购成功,num
减一。
这样会导致商品超发的情况,本来只有10件可以抢购的商品,可能会有超过10个人抢到,此时num
在抢购完成之后为负值。
解决该问题的方案由很多,可以简单分为基于mysql
和redis
的解决方案,redis
的性能要由于mysql
,因此可以承载更高的并发量,不过下面介绍的方案都是基于单台mysql
和redis
的,更高的并发量需要分布式的解决方案,本文没有涉及。
一、基于mysql的解决方案
商品表 goods
CREATE TABLE `goods` (
`id` int(11) NOT NULL,
`num` int(11) DEFAULT NULL,
`version` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
抢购结果表 log
CREATE TABLE `log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`good_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
①悲观锁
悲观锁的方案采用的是排他读,也就是同时只能有一个进程读取到num
的值。事务在提交或回滚之后,锁会释放,其他的进程才能读取。
该方案最简单易懂,在对性能要求不高时,可以直接采用该方案。要注意的是,SELECT … FOR UPDATE
要尽可能的使用索引,以便锁定尽可能少的行数;
排他锁是在事务执行结束之后才释放的,不是读取完成之后就释放,因此使用的事务应该尽可能的早些提交或回滚,以便早些释放排它锁。
$this->mysqli->begin_transaction();
$result = $this->mysqli->query("SELECT num FROM goods WHERE id=1 LIMIT 1 FOR UPDATE");
$row = $result->fetch_assoc();
$num = intval($row['num']);
if($num > 0){
usleep(100);
$this->mysqli->query("UPDATE goods SET num=num-1");
$affected_rows = $this->mysqli->affected_rows;
if($affected_rows == 1){
$this->mysqli->query("INSERT INTO log(good_id) VALUES({$num})");
$affected_rows = $this->mysqli->affected_rows;
if($affected_rows == 1){
$this->mysqli->commit();
echo "success:".$num;
}else{
$this->mysqli->rollback();
echo "fail1:".$num;
}
}else{
$this->mysqli->rollback();
echo "fail2:".$num;
}
}else{
$this->mysqli->commit();
echo "fail3:".$num;
}
②乐观锁
乐观锁的方案在读取数据是并没有加排他锁,而是通过一个每次更新都会自增的version
字段来解决,多个进程读取到相同num
,然后都能更新成功的问题。在每个进程读取num
的同时,也读取version
的值,并且在更新num
的同时也更新version
,并在更新时加上对version
的等值判断。
假设有10个进程都读取到了num
的值为1,version
值为9,则这10个进程执行的更新语句都是UPDATE goods SET num=num-1,version=version+1 WHERE version=9
,
然而当其中一个进程执行成功之后,数据库中version
的值就会变为10,剩余的9个进程都不会执行成功,这样保证了商品不会超发,num
的值不会小于0,但这也导致了一个问题,那就是发出抢购请求较早的用户可能抢不到,反而被后来的请求抢到了。
$result = $this->mysqli->query("SELECT num,version FROM goods WHERE id=1 LIMIT 1");
$row = $result->fetch_assoc();
$num = intval($row['num']);
$version = intval($row['version']);
if($num > 0){
usleep(100);
$this->mysqli->begin_transaction();
$this->mysqli->query("UPDATE goods SET num=num-1,version=version+1 WHERE version={$version}");
$affected_rows = $this->mysqli->affected_rows;
if($affected_rows == 1){
$this->mysqli->query("INSERT INTO log(good_id) VALUES({$num})");
$affected_rows = $this->mysqli->affected_rows;
if($affected_rows == 1){
$this->mysqli->commit();
echo "success:".$num;
}else{
$this->mysqli->rollback();
echo "fail1:".$num;
}
}else{
$this->mysqli->rollback();
echo "fail2:".$num;
}
}else{
echo "fail3:".$num;
}
③where条件(原子操作)
悲观锁的方案保证了数据库中num
的值在同一时间只能被一个进程读取并处理,也就是并发的读取进程到这里要排队依次执行。
乐观锁的方案虽然num
的值可以被多个进程同时读取到,但是更新操作中version
的等值判断可以保证并发的更新操作在同一时间只能有一个更新成功。
还有一种更简单的方案,只在更新操作时加上num>0
的条件限制即可。通过where
条件限制的方案虽然看似和乐观锁方案类似,都能够防止超发问题的出现,但在num
较大时的表现还是有很大区别的。
假如此时num
为10,同时有5个进程读取到了num=10
,对于乐观锁的方案由于version
字段的等值判断,这5个进程只会有一个更新成功,这5个进程执行完成之后num
为9;
对于where
条件判断的方案,只要num>0
都能够更新成功,这5个进程执行完成之后num
为5。
$result = $this->mysqli->query("SELECT num FROM goods WHERE id=1 LIMIT 1");
$row = $result->fetch_assoc();
$num = intval($row['num']);
if($num > 0){
usleep(100);
$this->mysqli->begin_transaction();
$this->mysqli->query("UPDATE goods SET num=num-1 WHERE num>0");
$affected_rows = $this->mysqli->affected_rows;
if($affected_rows == 1){
$this->mysqli->query("INSERT INTO log(good_id) VALUES({$num})");
$affected_rows = $this->mysqli->affected_rows;
if($affected_rows == 1){
$this->mysqli->commit();
echo "success:".$num;
}else{
$this->mysqli->rollback();
echo "fail1:".$num;
}
}else{
$this->mysqli->rollback();
echo "fail2:".$num;
}
}else{
echo "fail3:".$num;
}
二、基于redis的解决方案
①基于watch的乐观锁方案
watch
用于监视一个(或多个) key
,如果在事务执行之前这个(或这些) key
被其他命令所改动,那么事务将被打断。
这种方案跟mysql
中的乐观锁方案类似,具体表现也是一样的。
$num = $this->redis->get('num');
if($num > 0) {
$this->redis->watch('num');
usleep(100);
$res = $this->redis->multi()->decr('num')->lPush('result',$num)->exec();
if($res == false){
echo "fail1";
}else{
echo "success:".$num;
}
}else{
echo "fail2";
}
②基于list的队列方案
基于队列的方案利用了redis
出队操作的原子性,抢购开始之前首先将商品编号放入响应的队列中,在抢购时依次从队列中弹出操作,这样可以保证每个商品只能被一个进程获取并操作,不存在超发的情况。
该方案的优点是理解和实现起来都比较简单,缺点是当商品数量较多是,需要将大量的数据存入到队列中,并且不同的商品需要存入到不同的消息队列中。
public function init(){
$this->redis->del('goods');
for($i=1;$i<=10;$i++){
$this->redis->lPush('goods',$i);
}
$this->redis->del('result');
echo 'init done';
}
public function run(){
$goods_id = $this->redis->rPop('goods');
usleep(100);
if($goods_id == false) {
echo "fail1";
}else{
$res = $this->redis->lPush('result',$goods_id);
if($res == false){
echo "writelog:".$goods_id;
}else{
echo "success".$goods_id;
}
}
}
③基于decr返回值的方案
如果我们将剩余量num
设置为一个键值类型,每次先get
之后判断,然后再decr
是不能解决超发问题的。
但是redis
中的decr
操作会返回执行后的结果,可以解决超发问题。我们首先get
到num
的值进行第一步判断,避免每次都去更新num
的值,然后再对num
执行decr
操作,并判断decr
的返回值,如果返回值不小于0,这说明decr
之前是大于0的,用户抢购成功。
public function run(){
$num = $this->redis->get('num');
if($num > 0) {
usleep(100);
$retNum = $this->redis->decr('num');
if($retNum >= 0){
$res = $this->redis->lPush('result',$retNum);
if($res == false){
echo "writeLog:".$retNum;
}else{
echo "success:".$retNum;
}
}else{
echo "fail1";
}
}else{
echo "fail2";
}
}
④基于setnx的排它锁方案redis
没有像mysql
中的排它锁,但是可以通过一些方式实现排它锁的功能,就类似php使用文件锁实现排它锁一样。
setnx
实现了exists
和set
两个指令的功能,若给定的key
已存在,则setnx
不做任何动作,返回0;若key
不存在,则执行类似set
的操作,返回1。
我们设置一个超时时间timeout
,每隔一定时间尝试setnx
操作,如果设置成功就是获得了相应的锁,执行num
的decr
操作,操作完成删除相应的key
,模拟释放锁的操作。
public function run(){
do {
$res = $this->redis->setnx("numKey",1);
$this->timeout -= 100;
usleep(100);
}while($res == 0 && $this->timeout>0);
if($res == 0){
echo 'fail1';
}else{
$num = $this->redis->get('num');
if($num > 0) {
$this->redis->decr('num');
usleep(100);
$res = $this->redis->lPush('result',$num);
if($res == false){
echo "fail2";
}else{
echo "success:".$num;
}
}else{
echo "fail3";
}
$this->redis->del("numKey");
}
}