• 并发浅谈-锁和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

  • 相关阅读:
    luogu P1330 封锁阳光大学 x
    luoguP3353 在你窗外闪耀的星星
    luogu小金明qwq x
    [HDOJ5093] Battle ships(最大匹配)
    [HDOJ5092] Seam Carving(DP,记录路径)
    [UVA1449] Dominating Patterns(AC自动机,STL,计数,神坑)
    [POJ3057]Evacuation(二分图匹配,BFS,二分,好题)
    [POJ3041]Asteroids(二分图,最大匹配)
    [POJ2195]Going Home(带权最大匹配,KM,最小费用流)
    [codeVS1917] 深海机器人问题(费用流,拆边)
  • 原文地址:https://www.cnblogs.com/cuoreqzt/p/3824771.html
Copyright © 2020-2023  润新知