就很久之前自己写过的一篇关于mysql锁表的文章,http://paperen.com/post/lock-table,在目前来看实现上并不存在太大的问题,而当情况放在高并发的情形下那么该解决方案会变得不那么可行,原因是对数据库update操作实在频繁而且高并发情况下大量操作需要等待某个解锁后才能执行

就接着之前那篇文章说的情况

商店现在某商品只有1件库存,然后A与B在网上进行下订,A与B几乎同时(或许也就差几毫秒,A比B快那么一点点)进行

在这种应用情景下,我们先建立一个数据表 product,注意到使用了innoDB引擎

CREATE TABLE `product` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `num` mediumint(8) DEFAULT '0',
  `goods` varchar(60) CHARACTER SET latin1 DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `product` VALUES ('1', '1', 'test');

下面为基本php代码

<?php

$dbhost = '127.0.0.1';
$dbname = 'test';
$dbpwd = '';
$dbuser = 'root';

try {

 $conn = mysqli_connect($dbhost, $dbuser, $dbpwd);
 if ( !$conn ) throw new Exception('db error');
 mysqli_select_db($conn, $dbname);
 mysqli_query($conn,'set names UTF8');

 $id = 1;
 
 $sql = "select * from product where id='{$id}'";
 $query = mysqli_query($conn, $sql);
 $data = mysqli_fetch_array($query, MYSQLI_ASSOC);
 
 if ( $data['num'] <= 0 ) throw new Exception('product sold out');
 
 sleep(1);
 $sql = "update product set num=num-1 where id='{$id}'";
 mysqli_query($conn, $sql);
 echo 'ok';
 
} catch( Exception $e ) {
 echo $e->getMessage();
}

从代码上来看一点问题都没有,对吧?但是在实际情况下特别是并发的时候就会出现问题,当A与B同时发出请求,那么两人均能ok,而且库存会变成-1,为什么会这样子?因为是并发,当还没有update完成之前再进来一个请求,那么就上面这些代码是完全能跑通的,最后会让库存变为-1(这里加了sleep也是为了更容易重现这情况),使用jmeter可以测试出来

http://paperen.com/file/192

http://paperen.com/file/193

然后我们使用mysql的锁机制来防止这种情况,增加了两个函数lock与unlock,方便调用而已

$id = 1;
 
// 锁
lock('product', 1);

$sql = "select * from product where id='{$id}'";
$query = mysqli_query($conn, $sql);
$data = mysqli_fetch_array($query, MYSQLI_ASSOC);

if ( $data['num'] <= 0 ) throw new Exception('product sold out');

sleep(1);
$sql = "update product set num=num-1 where id='{$id}'";
mysqli_query($conn, $sql);
echo 'ok';

// 解锁
unlock('product');

function lock($table, $type = 0) {
 global $conn;
 $op = ( $type == 0 ) ? 'READ' : 'WRITE';
 $sql = "LOCK TABLE `{$table}` {$op}";
 mysqli_query($conn, $sql);
}

function unlock($table) {
 global $conn;
 $sql = "UNLOCK TABLES";
 mysqli_query($conn, $sql);
}

再使用jmeter测试,结果如下 http://paperen.com/file/194

http://paperen.com/file/195

这就是达到我们要的效果,因为后来的请求需要等待之前的请求更新完后才能查询库存,所以就防止了一起进入update的逻辑部分,但是基于数据库的解决方案在高并发时是很吃力的,可想在很多并发的情况下,这里操作数据库实在是太频繁了,会为数据库带来不少压力

到目前为止大概知道了一些问题

  • 避免直接操作数据库
  • 需要有队列的概念

正好redis里面存在一个数据结构为LIST,这正是完全符合了上面两点的解决方向,我们使用redis改进下代码

** init_redis.php 这里随便往redis初始化一个LIST,而LIST的数据长度取决于库存 **

<?php

$redis_host = '127.0.0.1';
$redis_port = 6379;
$redis_products_key = 'products';

$dbhost = '127.0.0.1';
$dbname = 'test';
$dbpwd = '';
$dbuser = 'root';

try {

 $redis = new redis();
 $redis->connect($redis_host, $redis_port);

 $conn = mysqli_connect($dbhost, $dbuser, $dbpwd);
 if ( !$conn ) throw new Exception('db error');
 mysqli_select_db($conn, $dbname);
 mysqli_query($conn,'set names UTF8');

 $id = 1;
 
 $sql = "select * from product where id='{$id}'";
 $query = mysqli_query($conn, $sql);
 $data = mysqli_fetch_array($query, MYSQLI_ASSOC);
 
 for($i=0;$i<$data['num'];$i++) $redis->lPush($redis_products_key, "test{$i}");
 
} catch( Exception $e ) {
 echo $e->getMessage();
}

** 操作库存的代码 **

<?php

$redis_host = '127.0.0.1';
$redis_port = 6379;
$redis_products_key = 'products';

$dbhost = '127.0.0.1';
$dbname = 'test';
$dbpwd = '';
$dbuser = 'root';

try {

 $redis = new redis();
 $redis->connect($redis_host, $redis_port);

 $conn = mysqli_connect($dbhost, $dbuser, $dbpwd);
 if ( !$conn ) throw new Exception('db error');
 mysqli_select_db($conn, $dbname);
 mysqli_query($conn,'set names UTF8');

 $id = 1;
 
 $product = $redis->lPop($redis_products_key);
 echo $product;
 if ( empty( $product ) ) {
  throw new Exception('product sold out');
 }
 echo 'ok';

 
} catch( Exception $e ) {
 echo $e->getMessage();
}

结果截图 http://paperen.com/file/196

http://paperen.com/file/197

可以看到这里使用了redis的lPop,给每请求过来的弹出一个值(可以理解为去餐馆吃饭时要的等待号),一旦没有号了证明库存已经为0,改进为这样之后,可想而知数据库的压力将大大减少,因为根本没有直接操作数据库,仅仅当redis的操作,但可以认识到的是在某个时刻看数据库的库存并不是真实的,实际的库存是等于 redis中LIST的长度 ,已售的商品数为 库存总数-redis中LIST的长度,所以要查询商品的当前库存的话就需要读取redis中LIST的长度而不是数据库中字段的数据,同时也应该建立一个机制,库存数同步回mysql中

最近面试过程也会被问到的这些问题,经过这个研究后大概也就不害怕涉及这些情况了,而当时自己只是回答了使用数据表的锁来实现,嗯~确实也是负分的答案

最后最后要感谢下豪神(@工程师的微博