关于程序并发
关于程序并发是老生常谈的话题了,工作中也经常去碰到,有必要来总结一下,其实并发与之关联的解决办法就是锁,加锁会消耗程序的性能和一些资源这是肯定的,当然如果能利用本身的原子性操作(指令的完整执行,在执行期间并不会被其他线程去中断,也不会存在上下文的切换),实现无锁编程是最好的。
1.防止重复请求
最近经常发现数据库中有连续数据结构完全相同的数据,可能是用户连续点击,造成数据库的重复操作,我们需要防止这种操作。解决办法有很多,可以加锁,或者利用自身php指令的原子性,将uri,userid,parms(确保没有随机数据),md5校验后存到seession中,先get(),没有说明有效请求,有说明无效请求,看似很合理,但是get(),set()这种存在坑,比如一个用户连续点击多次,多个请求被多个线程执行,此时执行指令顺序是无法预测的,比如说get()无值可以操作,然后就有set进session,在set到session的过程中可能另一个线程也get()到了没有结果,这样也就连续插入两次了,所以我们选择了redis操作,redis都是原子性的操作而且是单线程的。setnx()方法很好,如果有这个键值就返回false,没有就写入成功,原子性操作,完美!
/** * @return array 检查重复提交 */ public function checkRepeatSub() { $uri = app('request')->getRequestUri(); $userid = \App\Services\SaleCarCall\Util\Utils::getInstance()->getUserId(); $params = app('request')->all(); if (empty($params)) { return true; } $info = [ 'uri' => $uri, 'userid' => $userid, 'params' => $params ]; $key = 'RepeatSub.' . md5(json_encode($info)); $redis = RedisHelper::getRedisHandle(); if (!$redis->setnx($key,1)) { throw new DuplicateException('Duplicate request'); } else { $redis->expire($key,60); return true; } }
2.并发申请资源问题
我们的系统有时候会出现两个客服申请到了同一个资源的问题,这也是典型的并发的问题,原因有好几点,
2.1 因为采用的数据库的架构是主从分离,读写分离,一主多从的架构,自然而然的就是读落在了从库中,写落在了主库上,从库io线程读取主库的binlog,然后sql线程执行,而且从库的执行是单线程,主库是多线程并发的,所以无论如何主从一定会有延迟,这就会造成A申请到了一条线索,处理完状态应该是已处理,写到主库上面,因为延迟,从库中这条线索还没有改为已处理,那么这条线索就可能二次处理。所以对于及时性很高,状态变化很快的数据,建议还是读写都在主库。
2.2 我们客服的工作流程典型的是读->改->写的过程,其实是++i的原理是一样的,涉及到两次操作,++i涉及到两次的内存操作,我们是两次数据库的操作,数据库天然的锁机制为我们提供了方便。所以尽量将读写改封装成一个原子性的操作。对于++i的操作,个人也习惯了redis操作,也就是重复提交那一套。对于数据库的那一套个人认为redis操作也是有一些坑。
比如:我们加锁redis一定是要有过期时间的,如果执行过程中遇到错误或者抛出异常导致最后的delete锁没有执行,那么这个资源将一直被锁住,无法被其他人申请到,但是我们使用过期时间也是有坑的,正常额流程应该是A申请到了一条线索,申请一个redis锁(setnx),返回false,说明锁被占用,重新申请另一条线索,如果得到锁之后,do something,删除锁,看似读写改 在锁的机制下是原子性的操作,但是这个过期时间的设置很容易出错,比如说过期时间是2S,A申请到了id=1的线索,然后执行处理逻辑,但是处理逻辑执行了5S(可能发生),再第3S的时候,其实这个锁已经失效了,B可以申请到id=1的这个线索了,结果B执行花了1S,然后A执行完,更改数据库,B的操作记录就被覆盖了;或者A的执行完成时间在B之后,那么A删除的锁就不是删除自己加的那个锁了,A删除的就是B的锁,那么C就又可能申请到ID=1的这个线索,导致整个执行过程并不是原子性的了,出现混乱。所以最终我们选择了一种可以天然的利用数据库的行锁的机制(基于索引)。
原理是:在INNODB的存储引擎下,基于多版本的控制,读是不加锁的,(在读改写的机制下可能出现死锁),也就是ABC可能都申请到了id=1的资源,然后都要修改为自己名下的已处理,基于innodb的行锁机制,写操作是原子性的,A先修改,然后BC阻塞等待,A修改完,B修改,然后C修改,最后 A B的更新操作被C覆盖,这是典型的更新丢失的情况。所以,我们在设计数据库的时候,updatetime是数据库层面的,当ABc执行更新操作时候,where id=1 and updatetime=读取出来的时间,这样A更新完成后,updatetime被改变,BC不满足他们读取出来的时间和数据库时间一致的条件,更新失败,重新申请下一条的线索。
$retry = 0; while ($retry < 3) { $commonclue = SaleClueQueue::onWriteConnection()->select('id', 'updated_at')-> where('operator', 0)>AppointCallVars::NEXT_CALL_TYPE_MOREN)->where('is_double_appoint', self::DOUBLE_TYPE)-> orderBy('weight', 'asc')->first(); if (!empty($commonclue)) { $updateData = [ 'appoint_status' => AppointCallVars::APPOINT_STATUS_CALLING, 'operator' => Utils::getInstance()->getUserId(), ]; $flag = SaleClueQueue::where('id', $commonclue->id)->where('updated_at', $commonclue->updated_at)->update($updateData); if ($flag) { $log = self::$logPress . '申请到线索 : 结果 : ' . json_encode($nextcall); LogUtils::getInstance()->logInfo($log); break; } else { $retry++; } } else { $retry++; } }