PHP解决并发问题的常用手段
近期在公司项目中遇到一个积分交易功能,考虑到会对某些商户积分进行比较集中的更改。为保证数据的一致性,习惯性的就想在数据行加排他锁。不过因为担心数据库执行效率问题,就谨慎的去了解下多种处理方案,择优取之。
1、使用数据库悲观锁(排它锁)
通常来讲在数据库上的悲观锁需要数据库本身提供支持,即通过常用的select … for update操作来实现悲观锁。
当数据库执行select for update时会获取被select中的数据行的行锁,因此其他并发执行的select for update如果试图选中同一行则会发生排斥(需要等待行锁被释放),从而达到锁的效果。
select for update获取的行锁会在当前事务结束时自动释放,所以必须在事务中使用。
这里需要注意的一点是不同的数据库对select for update的实现和支持都是有所区别的。
例如Oracle支持select for update no wait,表示如果拿不到锁立刻报错,而不是等待。
MySQL就没有no wait这个选项。
另外MySQL还有个问题是select for update语句执行中所有扫描过的行都会被锁上,这一点很容易造成问题。因此如果在MySQL中用悲观锁务必要确定走了索引,而不是全表扫描。
需要注意一些锁的级别,MySQL InnoDB默认行级锁。所以只有明确地指定主键,MySQL才会执行行级锁 ,否则MySQL 将会执行Table Lock (表锁)。除了主键外,使用索引也会影响数据库的锁定级别。
2、使用代码级别的乐观锁
乐观锁在数据库上的实现完全是逻辑的,不需要数据库提供特殊的支持。一般的做法是在需要锁的数据上增加一个版本号,或者时间戳。
使用数据版本记录机制实现,这是乐观锁最常用的一种实现方式。
何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。
当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值+1。当我们提交更新时,将version字段作为更新条件,若数据被改变则version对应的数据就找到不,从而更新失败。
// 查询出商品信息
select status,version from items where id=${id}
// 更新该数据时将查询出来的verson值作为更新条件之一
update items set status=2,version=version+1 where id=${id} and version=${version}
乐观锁是否在事务中其实都是无所谓的,其底层机制是这样:
在数据库内部update同一行的时候是不允许并发的,即数据库每次执行一条update语句时会获取被update行的写锁,直到这一行被成功更新后才释放。
因此在业务操作进行前获取需要锁的数据的当前版本号,然后实际更新数据时再次对比版本号确认与之前获取的相同,并更新版本号,即可确认这之间没有发生并发的修改。
如果更新失败即可认为老版本的数据已经被并发修改掉而不存在了,此时认为获取锁失败,需要回滚整个业务操作并可根据需要重试整个过程。
3、通过PHP消息队列功能实现
将所有请求都存入一个列表中,这样将所有请求有序排列之后,通过另外的一个PHP循环取出列表中的数据并依次处理即可。
数据存储的列表介质有MySQL、Redis、RabbitMQ,三者各有优缺点。
消息队列中将入队和出队功能分开,这样可以使系统解耦,保证数据处理顺序,且能抵抗较大的并发访问,防止服务器承受不住导致崩溃。
也需要注意,服务端无法根据前端请求做出正确的回应,必须让前端配合做轮询或socket等获取最终的处理结果。
同时需要注意,消息处理使用死循环方式读取时,随易实现,但故障时无法即使恢复,且定时任务有处理上限等。
4、通过PHP+Redis线程锁
通过锁定PHP线程,将用户请求锁定在PHP代码阶段。通过线程锁定,只允许部分用户正常处理业务,更多用户则处于循环等待中。
// 设置自动解除锁定的有效期
$expire = time() + 2;
$key = 'lock';
$tab = true;
while ($tab) {
// 设置标志
$res = $redis->setnx($key, $expire);
// 若设置失败则说明已经有其他请求进入
if (!$res) {
// 检测过期时间,防止加锁用户因进程崩溃等问题造成无法解锁问题
$tm = $redis->get($key);
if ($tm < time()) {
// 超时删除锁
$redis->del($key);
}
// 等待10微秒
usleep(10);
} else {
// 关闭循环
$tab = false;
// 处理正常业务逻辑
// ...
// 处理完成后解锁
$redis->del($key);
}
}
5、通过PHP+Redis乐观锁
该方法主要通过Redis的事务监听功能实现。首先监听缓存到Redis中已卖出的数量,然后开启Redis事务,在执行事务提交时,若其他线程改变了缓存的卖出数量,则提交事务失败,数据修改回滚,结束后面业务逻辑的执行。
这种方式有很大弊端,虽然能保证同时只有一个用户成功,但提交事务后的数据库操作中有很大的隐患。前面部分代码因为是Redis操作,所以从监视到事务提交速度会很快,若并发量大,一样会对数据库造成冲击。
// 缓存已卖出的商品数量
$key = "sales";
// 商品总量
$limit = 1000;
// 监视该数据变化
$redis->watch($key);
$stock = $redis->get($key);
if ($stock >= $limit) {
return "库存已清";
}
// 开启事务
$redis->multi();
// 缓存数量加1
$redis->incr($key);
// 提交事务: 保证开始监视的key值没有变化才会成功提交
$res = $redis->exec();
if (!$res) {
return "执行失败";
}
// 更新数据库操作以及其他业务逻辑处理
// ...