并发浅谈-锁和Token的应用

摘要:
当并发性较大时,会发现它很突出。[令牌]令牌是令牌的含义。它有点像blackwood令牌,一种验证身份/会话有效性的机制。它通常用于SSO系统。Token一般具有以下特点:1:唯一性,每个ID都是唯一的----------------------------------------------------------------------第三个版本的代码:---------------------------------------------functiongetCode(){//防止并发usleep;//获取未使用的激活代码$row=$db-˃fetchRow;//锁定激活代码$db-˃execute;return$row['code';}注释:此处添加了随机休眠进程的机制,与随机排序相结合,这比版本2优化得多,但仍然不能从根本上解决重复问题。

并发


即在同一时刻内有多个完成同一任务的进程或线程在同时运行。
并发一般发生在大流量集中访问如抢购或秒杀等业务场景中,它所带来的影响主要表现在以下两个方面:
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的应用》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇ROC与AUC学习【模板】矩阵加速(数列) 矩阵快速幂下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

VUE Flask登录的初探-JWT的探索

上回简单实现了基于JWT的登录,并且留下了一些问题,jwt天生的弊端。本次用某些逻辑解决jwt的弊端 先列举jwt可能遇到的问题: 1.注销问题,当客户端注销登录后,token在有效期内依然有效,实际上从服务端无法让token失效2.修改密码,当用户修改了密码,按常规需要让前次token失效。3.续签问题,jwt虽然有超时机制,但没有实现自动续签。 为了解...

上传永久图文素材-公共方法

package com.epalmpay.util;import com.alibaba.fastjson.JSON;import com.riversoft.weixin.common.oauth2.AccessToken;import net.sf.json.JSONObject;import org.apache.http.HttpEntity;im...

(办公)TOKEN

  token就是HTTP认证,输入正确的token,在放在Authorization header中发送给服务器,认证成功。,就可以正确的拿到接口数据.       举个例子:   第一步:  客户端发送http request 给服务器,    第二步:  因为request中没有包含Authorization header,  服务器会返回一个401...

SpringBoot实现JWT认证

SpringBoot实现JWT认证 本文会从Token、JWT、JWT的实现、JWTUtil封装到SpringBoot中使用JWT,如果有一定的基础,可以跳过前面的内容~ Token 简介 Token 是一个临时、唯一、保证不重复的令牌,例如智能门锁,它可以生成一个临时密码,具有一定时间内的有效期。 实现思路 UUID具有上述的特性,所以我们可以使用UUI...

带token的get和post方法

GET和POST传值失败,多半是传输的字符串和URL的事 1 public static string ExcuteGetToken(string serviceUrl, string ReqInfo, string token) 2 { 3 using (null) 4 { 5 ServicePointManag...

微信定时获取token

为了使第三方开发者能够为用户提供更多更有价值的个性化服务,微信公众平台开放了许多接口,包括自定义菜单接口、客服接口、获取用户信息接口、用户分组接口、群发接口等,开发者在调用这些接口时,都需要传入一个相同的参数access_token,它是公众账号的全局唯一票据,它是接口访问凭证。 access_token的有效期是7200秒(两小时),在有效期内,可以一直...