首页 > 代码库 > 并发浅谈-锁和Token的应用

并发浅谈-锁和Token的应用

并发


即在同一时刻内有多个完成同一任务的进程或线程在同时运行。
并发一般发生在大流量集中访问如抢购或秒杀等业务场景中,它所带来的影响主要表现在以下两个方面:
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