这两天遇到一个常见的并发控制的问题,类似抢票问题,当剩下一张票的时候 两个人同时抢这张票 可能会出现多卖的情况
在发短信的时候 一般会限制一个手机号发送一次的时间间隔在60s 我们的代码大概会这么写
1 2 3 4 5 6 7 8 9 10 # 获取上次发送的时间 $time = getLastSendTime(); if (time() - $time > 60) { sendSms(); # 更新最后发送时间 updateLastSendTime(time()); } else { 不能发送 }
看似严谨的逻辑 但是在并发的情况下 同一时间 两个请求发送过来的话 情况就不一样了 可能第一个请求还没有执行到 updateLastSendTime
第二个请求就已经执行到判断语句了 这个时候 程序判断会允许这个请求发送短信的,当请求是成千上万的并发 短信就会灾难性的被刷掉了
同样在上面买票的场景,并发过来的时候,会出现超卖的情况
看下面代码,重现下这个问题 (这里为了简化问题 使用文件代替redis 等持久化存储)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 <?php $interval = 60; if (time() - getLastSendTime() > $interval) { $msg = "send sms"; echo $msg; _log($msg); updateLastSendTime(); } else { $msg = "please request after $interval s"; echo $msg; _log($msg); } /* 更新最后发送时间 */ function updateLastSendTime() { return file_put_contents('time', time()); } /* 获取最后发送时间 */ function getLastSendTime() { if (!file_exists('time')) { updateLastSendTime(); return time(); } return file_get_contents('time'); } /* 日志记录 */ function _log($content) { file_put_contents('sendsms.log', date('Y-m-d H:i:s') . " : " . $content . "\n", FILE_APPEND); }
当一个请求触发脚本 我们收到日志
1 2017-04-06 10:57:16 : send sms
当我们果断时间再次请求 我们收到日志
1 2017-04-06 10:57:36 : please request after 60 s
这个是我们一般情况下的状态,下面我们使用 ab(apache beanch)
工具来测试下并发的情况
我们发送5个用户 的并发
1 ab -c 5 -n 5 http://localhost/lock.php
这个时候我们得到的日志是这样的
1 2 3 4 5 2017-04-06 11:04:22 : send sms 2017-04-06 11:04:22 : send sms 2017-04-06 11:04:22 : send sms 2017-04-06 11:04:22 : please request after 60 s 2017-04-06 11:04:22 : please request after 60 s
同一时刻发送的五个请求 前三个都成功了 你的逻辑被羞辱了 ==!
注意 这里产生的原因是进程问题导致 如果你使用php自带的servphp -S 127.0.0.1:8081/lock.php
就不会出现这个问题 那是因为他是单进程/单线程运行的 所有的请求是进行排队的
但是如果你使用的nginx
那个这个问题会很明显,因为nginx有强大的吞吐量(基于epoll模型) 你开的进程越多 问题越明显,我这里开发环境开的是三个nginx进程 正对应了上面的日志
当然线上服务为了更好服务更好的请求 会开更多的进程。
那个如何解决这样的问题呢? 这两天实验了两种方法
队列
文件锁
队列一个目的是为了 代码解耦,这里要把整个逻辑代码搬到队列 不太合适 文件锁可以在控制器层来做,且按需求来做,但是高并发用户量的时候这么做可能会让其他请求用户一直等待 可能等到http请求60秒超时…
这里使用文件锁
的方法先解决这个问题
1 2 3 4 5 6 7 8 #给文件上锁 # source 是文件资源句柄 fopen() 的返回 # LOCK有以下几个取值 # LOCK_EX 排他锁 被锁定的文件 在被其他进程上锁的时候 需要阻塞等待 文件被解锁 # LOCK_SH 共享锁 # LOCK_UN 释放锁 # LOCK_NB 非阻塞 仅适用于 linux bool flock(source, LOCK)
下面加入锁的功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 lock(function () { if (time() - getLastSendTime() > 10) { $msg = "send sms"; echo $msg; _log($msg); updateLastSendTime(); } else { $msg = "please request after 60 s"; echo $msg; _log($msg); } }); function lock($callback) { $fp = fopen('.lock', 'w+'); if (flock($fp, LOCK_EX)) { call_user_func($callback); } fclose($fp); } ......
这个时候再次
1 ab -c 5 -n 5 http://localhost/lock.php
得到日志:
1 2 3 4 5 2017-04-06 11:21:32 : send sms 2017-04-06 11:21:32 : please request after 60 s 2017-04-06 11:21:32 : please request after 60 s 2017-04-06 11:21:32 : please request after 60 s 2017-04-06 11:21:32 : please request after 60 s
只有第一次执行成功,后面的都失败 判断成功了。 加锁的目的就是让第一个请求先执行完(记录最后一次发送的时间),在执行第二个请求 这样判断才能在并发的场景生效
并发请求 数据库重复插入数据 解决办法 1. mysql 唯一索引 2. mysql 加锁 MyISAM 表级锁 InnoDB 行级锁
Write Read
3. redis 加锁 或者文件加锁 4. 队列