共计 5075 个字符,预计需要花费 13 分钟才能阅读完成。
1、前言
为什么要构建锁呢?因为构建合适的锁可以在高并发下能够保持数据的一致性,即客户端在执行连贯的命令时上锁的数据不会被别的客户端的更改而发生错误。同时还能够保证命令执行的成功率。
看到这里你不禁要问 Redis 中不是有事务操作么?事务操作不能够实现上面的功能么?
的确,redis 中的事务可以 watch 可以监控数据,从而能够保证连贯执行的时数据的一致性,但是我们必须清楚的认识到,在多个客户端同时处理相同的数据的时候,很容易导致事务的执行失败,甚至会导致数据的出错。
在关系型数据库中,用户首先向数据库服务器发送 BEGIN,然后执行各个相互一致的写操作和读操作,最后用户可以选择发送 COMMIT 来确认之前的修改,或者发送 ROLLBACK 进行回滚。
在 redis 中,通过特殊的命令 MULTI 为开始,之后用户传入一连贯的命令,最后 EXEC 为结束(在这一过程中可以使用 watch 进行监控一些 key)。进一步分析,redis 事务中的命令会先推入队列,等到 EXEC 命令出现的时候才会将一条条命令执行。假若 watch 监控的 key 发生改变,这个事务将会失败。这也就说明 Redis 事务中不存在锁,其他客户端可以修改正在执行事务中的有关数据,这也就为什么在多个客户端同时处理相同的数据时事务往往会发生错误。
2、简单理解 redis 的单线程 IO 多路复用
Redis 采用单线程 IO 多路复用模型来实现高内存数据服务。何为单线程 IO 多路复用呢?从字面的意思可以知道 redis 采用的是单线程、使用的是多个 IO。整个过程简单的来讲就是,哪个命令的数据流先到达就先执行。
请看下面的形象理解图:图中是一座窄桥, 只能允许一辆车通过 ,左边是车辆进入的通道,哪一辆车先到达就先进入。即哪个 IO 流先到达就先处理哪个。
Linux 下网络 IO 使用 socket 套接字来通讯,普通 IO 模型只能监听一个 socket,而 IO 多路复用可同时监控多个 socket。IO 多路复用避免阻塞在 IO 上,单线程保存多个 socket 的状态后轮循处理。
3、并发测试
我们就模拟一个简单典型的并发测试,然后从这个测试中得出问题,再进一步研究。
并发测试思路:
1、在 redis 中设置一个字符串 count,运用程序将其取出来加 +1,再存储回去,一直循环十万次
2、在两个浏览器上同时执行这个代码
3、将 count 取出来,查看结果
测试步骤:
1、建立 test.php 文件
1 <?php
2 $redis=new Redis();
3 $redis->connect('192.168.95.11','6379');
4 for ($i=0; $i < 100000; $i++)
5 { 6 $count=$redis->get('count');
7 $count=$count+1;
8 $redis->set('count',$count);
9 }
10 echo "this OK";
11 ?>
2、分别在两个浏览器中访问 test.php 文件
结果由上图可知,总共执行两次,count 原本应该是二十万才对的,但实际上 count 等于十三万多,远远小于二十万,这是为什么呢?
由前面的内容可知,redis 是采用单线程 IO 多路复用模型的。因此我们使用两个浏览器即为两个会话(A、B),取出、加 1、存入这三个命令并不是原子操作,并且在执行取出、存入这两个 redis 命令时是哪个客户端先到就先执行。
例如:1、此时 count=120
2、A 取出 count=120,紧接着 B 的取出命令流到了,也将 count=120 取出
3、A 取出后立即加 1,并将 count=121 存回去
4、此时 B 也紧跟着,也将 count=121 存进去了
注意:
1、设置循环次数尽量大一点,太小的话,当在第一个浏览器执行完毕,第二个浏览器还没开始进行呢
2、必须要两个浏览器同时执行。假若在一个浏览器中同时执行两次 test.php 文件,不管是否同时执行,最终结果就是 count=200000。因为在同一个浏览器中执行,都是属于同一个会话(所有命令都在同一个通道通过),所以 redis 会让先执行的十万次执行完,再接着执行其他的十万次。
4、事务解决与原子性操作解决
4.1、事务解决
更改后的 test.php 文件
1 <?php
2 header("content-type: text/html;charset=utf8;");
3 $start=time();
4 $redis=new Redis();
5 $redis->connect('192.168.95.11','6379');
6
7 for ($i=0; $i < 100000; $i++)
8 { 9 $redis->multi();
10 $count=$redis->get('count');
11 $count=$count+1;
12 $redis->set('count',$count);
13 $redis->exec();
14 }
15 $end=time();
16 echo "this OK<br/>";
17 echo "执行时间为:".($end-$start);
18 ?>
执行结果失败,表名使用事务不能够解决此问题。
分析原因:
我们都知道当 redis 开启时,事务中的命令是不执行的,而是先将命令压入队列,然后当出现 exec 命令的时候,才会阻塞式的将所有的命令一个接一个的执行。
所以当使用 PHP 中的 Redis 类进行 redis 事务的时候,所有有关 redis 的命令都不会真正的执行,而仅仅是将命令发送到 redis 中进行存储起来。
因此下图中所圈到的 $count 实际上不是我们想要的数据,而是一个对象,因此 test.php 中 11 行出错。
查看对象 count:
4.2、原子性操作 incr 解决
#更新 test.php 文件
1 <?php
2 header("content-type: text/html;charset=utf8;");
3 $start=time();
4 $redis=new Redis();
5 $redis->connect('192.168.95.11','6379');
6 for ($i=0; $i < 100000; $i++)
7 { 8 $count=$redis->incr('count');
9 }
10 $end=time();
11 echo "this OK<br/>";
12 echo "执行时间为:".($end-$start);
13 ?>
两个浏览器同时执行,耗时 14、15 秒,count=200000,可以解决此问题。
缺点:
仅仅只是解决这里的取出加 1 的问题,本质上还是没能解决问题的,在实际环境中,我们需要做的是一系列操作,不仅仅只是取出加 1, 因此就很有必要构建一个万能锁了。
5、构建分布式锁
我们构造锁的目的就是在高并发下消除选择竞争、保持数据一致性
构造锁的时候,我们需要注意几个问题:
1、预防处理持有锁在执行操作的时候进程奔溃,导致死锁,其他进程一直得不到此锁
2、持有锁进程因为操作时间长而导致锁自动释放,但本身进程并不知道,最后错误的释放其他进程的锁
3、一个进程锁过期后,其他多个进程同时尝试获取锁,并且都成功获得锁
我们将不对 test.php 文件修改了,而是直接建立一个相对比较规范的面向对象 Lock.class.php 类文件
#建立 Lock.class,php 文件
1 <?php
2 # 分布式锁
3 class Lock
4 { 5 private $redis=''; # 存储 redis 对象
6 /**
7 * @desc 构造函数
8 *
9 * @param $host string | redis 主机
10 * @param $port int | 端口
11 */
12 public function __construct($host,$port=6379)
13 { 14 $this->redis=new Redis();
15 $this->redis->connect($host,$port);
16 }
17
18 /**
19 * @desc 加锁方法
20 *
21 * @param $lockName string | 锁的名字
22 * @param $timeout int | 锁的过期时间
23 *
24 * @return 成功返回 identifier/ 失败返回 false
25 */
26 public function getLock($lockName, $timeout=2)
27 { 28 $identifier=uniqid(); # 获取唯一标识符
29 $timeout=ceil($timeout); # 确保是整数
30 $end=time()+$timeout;
31 while(time()<$end) # 循环获取锁
32 { 33 if($this->redis->setnx($lockName, $identifier)) # 查看 $lockName 是否被上锁
34 { 35 $this->redis->expire($lockName, $timeout); # 为 $lockName 设置过期时间,防止死锁
36 return $identifier; # 返回一维标识符
37 }
38 elseif ($this->redis->ttl($lockName)===-1)
39 { 40 $this->redis->expire($lockName, $timeout); # 检测是否有设置过期时间,没有则加上(假设,客户端 A 上一步没能设置时间就进程奔溃了,客户端 B 就可检测出来,并设置时间)
41 }
42 usleep(0.001); # 停止 0.001ms
43 }
44 return false;
45 }
46
47 /**
48 * @desc 释放锁
49 *
50 * @param $lockName string | 锁名
51 * @param $identifier string | 锁的唯一值
52 *
53 * @param bool
54 */
55 public function releaseLock($lockName,$identifier)
56 { 57 if($this->redis->get($lockName)==$identifier) # 判断是锁有没有被其他客户端修改
58 { 59 $this->redis->multi();
60 $this->redis->del($lockName); # 释放锁
61 $this->redis->exec();
62 return true;
63 }
64 else
65 { 66 return false; # 其他客户端修改了锁,不能删除别人的锁
67 }
68 }
69
70 /**
71 * @desc 测试
72 *
73 * @param $lockName string | 锁名
74 */
75 public function test($lockName)
76 { 77 $start=time();
78 for ($i=0; $i < 10000; $i++)
79 { 80 $identifier=$this->getLock($lockName);
81 if($identifier)
82 { 83 $count=$this->redis->get('count');
84 $count=$count+1;
85 $this->redis->set('count',$count);
86 $this->releaseLock($lockName,$identifier);
87 }
88 }
89 $end=time();
90 echo "this OK<br/>";
91 echo "执行时间为:".($end-$start);
92 }
93
94 }
95
96 header("content-type: text/html;charset=utf8;");
97 $obj=new Lock('192.168.95.11');
98 $obj->test('lock_count');
99
100 ?>
测试结果:
在两个不同的浏览器中执行,最终结果 count=200000,但是耗时相对较多,需要近八十多秒左右。但是在高并发下,对同一个数据,二十万次上锁执行释放锁的操作还是可以接受的,甚至已经很不错了。
以上的简单例子仅仅只是为了模拟并发测试并检验而已,实际上我们可以使用 Lock.class.php 中的锁结合自己的项目加以修改就可以很好地使用这个锁了。例如商城中的疯狂抢购、游戏中虚拟商城玩家买卖东西等等。
本文永久更新链接地址 :http://www.linuxidc.com/Linux/2017-09/146927.htm