并发
即在同一时刻内有多个完成同一任务的进程或线程在同时运行。
并发一般发生在大流量集中访问如抢购或秒杀等业务场景中,它所带来的影响主要表现在以下两个方面:
1:造成系统的负载压力过大。比如说mysql天生在处理大并发时表现的异常吃力,并发大时经常可以造成数据库挂掉。
2:造成业务资源的竞争出现。比如说兑换一个激活码,并发下可能会出现两个人同时兑换到的同一个激活码。
从开发的经验来看,一般开发者在写程序逻辑时,绝大多数的情况下是没有考虑并发问题的;这其中有两个方面,一是与业务有关,二是与经验有关;其中经验是最重要的,缺乏经验的开发者甚至很难分析一个业务中是否要考虑并发问题。从一般的经验来说:凡是有竞争资源存在的业务中,一般都要考虑到并发问题。
既然并发竟然这么重要,那应该如何来测试了?
测试并发的问题上,开发者不要太把希望寄托在测试人员身上了,很多一般的测试人员可以把你的功能测得基本没有BUG,但对并发这种性能性的测试缺少相关经验。最好的办法是自己写一个并发专用测试用例,然后采用 Apache ab 工具进行并发的模似测试,有关Apache ab 工具的使用请自行查google。
锁
锁是为了保障数据一致性的一种保护方式,举例来说:如果多个人同时对同一个文件进行读写操作,如果不给文件加锁则会产生意想不到的结果。
锁一般用得多的是:共享锁定(其它程序可以同时读);独占锁定(其它程序靠边站)
我们在PHP中应用最多的有以下三种锁:
1:内存锁
在PHP中可以利用如共享内存的机制来实现,或者直接使用opcode扩展中的eaccelerator(PS)直接提供的相关锁函数.在常规操作中,内存锁的效率是最高的。
2:文件锁
PHP中打开一个文件时可以加不同类型的锁.
3:mysql表锁
mysql内部数据在操作时它会采用队列的方式来处理同一时发来的查询,所以大家不要担心并发查询时它会处理异常的情况。对外它提供的表锁,主要是为了满足我们的业务需要,它是基于线程的。有一点要注意:表锁应用时mysql要损很大的性能。并发大时发现突出。
[经验之谈]:
当我们没有可用的资源来实现内存锁时,可以采用linux下的 /dev/shm 挂接点,这个目录是内存区域的一个映射,即在这个目录中存入文件相当于存入内存中,IO性能肯定远高于磁盘文件的IO了。所以我们可以对这个目录下的特定文件进行加锁,从到达到内存锁的高性能。
(PS):
opcode优化扩展有:(APC,XCache,eAccelerator)具体使用和优化可以看资料整理http://www.cnblogs.com/cuoreqzt/p/3824757.html
从服务器性能优化来讲,opcode优化扩展是一个非常重要的环节,从专业的性能测试可以看出,opcode优化能提高PHP的执行性能很多,表现出来就是搞高并发数。
[Token]
Token 是令牌的意思,有点像任我任的黑木令,一种检验身份/会话合法性的一种机制,一般在SSO这种系统中应用得比较多。
Token 一般有以下几个特性:
1:唯一性,即每个ID都是唯一的。
2:时间有效性,即存在过期时间。
3:一次性使用,即使用一次后就失效。
综合以上特性,我们很自然的想到用缓存机制可以很方便实现Token功能,基于扩展性和性能的考虑,memcache是首选,但不仅限于它,只要可以符合这三点,其它方法也行,比如说 apc,file 等。
[实例应用]
业务场景说明:
网站免费发放购物优惠卷激活码,但每天只放100个免费的,这样就会造成用户每天在 24:00 时集中来兑换。这个需求好像很简单,但存在着并发问题。
以下从最简单的版本开始讲解:
----------------------------------
第1个版本的代码:
----------------------------------
function getCode(){ // 得到一个没有使用的激活码 $row = $db->fetchRow(‘select id,code from codes where stat=0‘); // 将激活码锁定 $db->execute(‘update codes set stat=1 where id=‘.$row[‘id‘]); return $row[‘code‘]; }
解说:
这个代码单个执行时是没有BUG吧,但这里存在严重的并发问题,因为此时slelct后的结果都按默认的排序,所以多个进程同时取时,就取到了同一个激活码。
----------------------------------
----------------------------------
第2个版本的代码:
----------------------------------
function getCode(){ // 得到一个没有使用的激活码 $row = $db->fetchRow(‘select id,code from codes where stat=0 order by rand()‘); // 将激活码锁定 $db->execute(‘update codes set stat=1 where id=‘.$row[‘id‘]); return $row[‘code‘]; }
解说:
采用随机排序后可以降低并发时出现同一个激活码,但并发大时还是会出现大量重复的情况。
----------------------------------
----------------------------------
第3个版本的代码:
----------------------------------
function getCode(){ // 防止并发 usleep(mt_rand(1000,10000)); // 得到一个没有使用的激活码 $row = $db->fetchRow(‘select id,code from codes where stat=0 order by rand()‘); // 将激活码锁定 $db->execute(‘update codes set stat=1 where id=‘.$row[‘id‘]); return $row[‘code‘]; }
解说:
这里加了随机休眠进程的机制,再结合随机排序,比版本2是优化了很多,但还是不能从根本上解决重复的问题。并且这种方式又会带来新的并发性能问题。因为你增加了响应时间。
----------------------------------
----------------------------------
第4个版本的代码:
----------------------------------
function getCode(){ // 锁表 $db->execute(‘lock tables codes write‘); // 得到一个没有使用的激活码 $row = $db->fetchRow(‘select id,code from codes where stat=0 order by rand()‘); // 将激活码锁定 $db->execute(‘update codes set stat=1 where id=‘.$row[‘id‘]); // 解锁 $db->execute(‘unlock tables‘); return $row[‘code‘]; }
解说:
这里给表加了独占的写锁,其它MYSQL线和在我没有处理完前都要靠边站;但这里有性能问题,前面我说过mysql的锁表机制很损性能的,并且这样有很大的风险,因为一但表没有得到解锁的话,越来越多的连接线程就全卡着不动了,变动sleep状态了。一个网站的性能瓶颈很大程度上就是DB的并发处理能力,这样更降低的DB的并发能力。所以这个方案性价比不是很高。
----------------------------------
----------------------------------
第5个版本的代码:
----------------------------------
/* [内存锁] 如果服务器没安装 eAccelerator 扩展,则可以采用 eaccelerator_lock() eaccelerator_unlock() 性能更好. 这里采用拆中方案: /dev/shm */ class memLock{ static private $_fp = null; // 加锁 static public function lock(){ if(null === self::$_fp){ self::$_fp = fopen(‘/dev/shm/score-exchange.txt‘, ‘w+‘); } return flock($_fp, LOCK_EX); } // 解锁 static public function unlock(){ flock($_fp, LOCK_UN); clearstatcache(); } } function getCode(){ // 锁进程 memLock::lock(); $code = _get(); // 解锁 memLock::unlock(); return $code; } function _get(){ // 得到一个没有使用的激活码 $row = $db->fetchRow(‘select id,code from codes where stat=0 order by rand()‘); // 将激活码锁定 $db->execute(‘update codes set stat=1 where id=‘.$row[‘id‘]); return $row[‘code‘]; }
解说:
这里将表锁的性能开销换成了性能更好的内存进程锁,与上一个版本相比,这个性能比有所改进,提高了性能。但这个方案还是可能会出现异常现象,特别是被恶意机器人来刷激活码时。因为一般的兑换请求可能是:
GET /exchange?userid=5
要写个机器人来刷还是不难,可以利用工具或利用 curl,类似以下过程:
curl ‘<登录>‘
curl ‘/exchange?userid=5‘
我们可以优化一点,考虑从源头来控制被刷的问题。
----------------------------------
----------------------------------
最后版的代码:
----------------------------------
/* [内存锁] 如果服务器没安装 eAccelerator 扩展,则可以采用 eaccelerator_lock() eaccelerator_unlock() 性能更好. 这里采用拆中方案: /dev/shm */ class memLock{ static private $_fp = null; // 加锁 static public function lock(){ if(null === self::$_fp){ self::$_fp = fopen(‘/dev/shm/score-exchange.txt‘, ‘w+‘); } return flock($_fp, LOCK_EX); } // 解锁 static public function unlock(){ flock($_fp, LOCK_UN); clearstatcache(); } } /** * Token 处理 */ class Token{ private $_cache = null; /** * 缓存对象实例 * */ private static $instance = null; /** * 以单例模式返回实例 * */ static public function getInstance() { if (null === self::$instance) { self::$instance = new self(); } return self::$instance; } /** * 构造函数 * */ public function __construct(){ $this->_cache = new Memcache; $this->_cache->addServer(‘10.10.2.104‘,‘11211‘); } /** * 验证 Token * * @param unknown_type $token : Token值 */ public function check($tokenid){ $id = $this->_get(); if(!$id || $id!=$tokenid){ return false; }else{ // Token特性1:一次性用品 $this->_set(‘‘); return true; } } /** * 得到 Token ID * */ public function get(){ // Token特性2:唯一性 $token = md5(uniqid(time().rand().$_COOKIE[‘userid‘])); $this->_set($token); return $token; } // 得到缓存key private function _key(){ return ‘tokon‘.$_COOKIE[‘userid‘]; } // 设置缓存 private function _set($token){ // 轮循算法是为了尽量的处理TCP连接失效 $i = 0; while($i < 5){ // Token特性3:时效性 $ret = $this->_cache->set($this->_key() , $token, MEMCACHE_COMPRESSED, 10); if($ret) break; ++$i; } } // 取缓存 private function _get(){ // 轮循算法 $i = 1; while($i < 5){ $ret = $this->_cache->get($this->_key() , MEMCACHE_COMPRESSED); if($ret !== FALSE) break; ++$i; } return $ret; } }
========================================
兑换流程步骤拆分(任务拆分为3步)
========================================
步骤1:
登录后写一个特殊的COOKIE用于标识用户是在浏览器中正常登录的行为:
-----------------------------------------------------------
function loginCallBack(){ $cokname = md5(‘exchange‘.$this->userid.$this->sessionid); if(!isset($_COOKIE[$cokname])){ setcookie($cokname, 1); } }
说明:
这个算法是为了保证每个用户每次正常登录的COOKIE都不一样,注意在实际中不要写得太明显了,你可以考虑在另一个不相干的任务做做这个事情,劈开破解者的注意视线,增加破解难度。同时写好注释。
-----------------------------------------------------------
步骤2:
得到一次兑换请求的Token信息
-----------------------------------------------------------
function getToken(){ // 是否是正常登录的用户 $cokname = md5(‘exchange‘.$this->userid.$this->sessionid); if(!isset($_COOKIE[$cokname]) || $_COOKIE[$cokname]!=1){ $token = 0; }else{ $token = Token::getInstance()->get(); } $this->outputJson(0,‘ok‘, $token); }
-----------------------------------------------------------
步骤3:
改变前端javascript兑换的逻辑代码如下:
----------- 原逻辑 ----------------------------------------
function exchange(){ var url = ‘/exchange?userid=5‘; $.get(url,function(ret){ alert(ret.data); },‘json‘); }
----------- 新逻辑 ----------------------------------------
function exchange(){ var url = ‘/getToken‘; $.get(url,function(ret){ url = ‘/exchange?userid=5&tk=‘+ret.data; $.get(url,function(ret){ alert(ret.data); },‘json‘); },‘json‘); }
-----------------------------------------------------------
function getCode(){ // Token 信息是否正确 $token = $this->getGet(‘tk‘,0); if($token == 0 || !Token::getInstance()->check($token)){ $this->outputJson(-1,‘非法请求‘); } // 锁进程 memLock::lock(); $code = _get(); // 解锁 memLock::unlock(); return $code; } function _get(){ // 得到一个没有使用的激活码 $row = $db->fetchRow(‘select id,code from codes where stat=0 order by rand()‘); // 将激活码锁定 $db->execute(‘update codes set stat=1 where id=‘.$row[‘id‘]); return $row[‘code‘]; }
解说:
现在可以最大限制的防止恶意刷的行为了,当同一个兑换请求: /exchange?userid=5&tk=xxxxx 再次执行时将会失效,因为它的Token信息已经失效了.
----------------------------------
总结:
1:这里只是对并发的处理进行的简单的描述,给读者一点启发。
2:也可以采用 Innodb 的事务来处理或存储过程来处理。
3:解决并发最好的算法应该是采用队列的机制,据我所了解的资料,解决并发其实最方便编程的应该是 MongoDB 中的 findAndModify 操作,因为MongoDB 是专为Web开发所设计的一种NoSql型的DBMS系统,它天生对大请求量的并发处理有着非常高效的性能,天生支持原子操作。
有关 MongoDB 的详细资源推荐看看《MongoDB权威指南》
有关 MongoDB 的安装配置可以参考: http://vquickphp.com/?a=blogview&id=31
有关 MongoDB 的PHP应用可以参考: http://vquickphp.com/?a=blogview&id=32
并发浅谈-锁和Token的应用