zoukankan      html  css  js  c++  java
  • 并发浅谈-锁和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

  • 相关阅读:
    December 23rd 2016 Week 52nd Friday
    December 22nd 2016 Week 52nd Thursday
    December 21st 2016 Week 52nd Wednesday
    December 20th 2016 Week 52nd Tuesday
    December 19th 2016 Week 52nd Sunday
    December 18th 2016 Week 52nd Sunday
    uva294(唯一分解定理)
    uva11624Fire!(bfs)
    fzu2150Fire Game(双起点bfs)
    poj3276Face The Right Way
  • 原文地址:https://www.cnblogs.com/cuoreqzt/p/3824771.html
Copyright © 2011-2022 走看看