各位听众朋友,大家好!
欢迎来到今天的《PHP数据库排忧解难系列讲座》。我是你们的老朋友,那个昨天还在修Bug,今天就来给你们讲大道理的资深程序员。
今天我们要聊的是一个让无数PHP开发者闻风丧胆的话题——数据库锁表。
是不是感觉这个名字就很刺耳?听着就像是你辛辛苦苦写的代码,正准备提交到服务器,结果数据库突然翻了个白眼,把门一锁:“今天谁也别想进来,除非你等上一百年。”
如果你现在的项目已经出现了这种情况——高并发下响应慢得像蜗牛,或者直接报错 Lock wait timeout exceeded——别慌,我也经历过。这种时候,你的服务器日志像瀑布一样刷屏,老板在微信上疯狂@你,而你只能默默地点开百度,输入“MySQL锁表”,然后一脸茫然。
今天,我就要带你把“锁表”这个潘多拉魔盒打开,看看里面到底藏着什么怪兽,以及作为PHP开发者,我们手里有什么武器能把它打死。
第一章:诊断,你的数据库是不是在“闹脾气”?
首先,我们要学会判断。锁表不一定是那种“死锁”那种剧烈的争吵,有时候它表现为一种冷漠的拒绝。
最常见的症状就是你的网站突然变慢,或者某些特定的接口直接报错。拿PHP的话,你可能会看到类似这样的报错:
PDOException: SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction
这句话翻译过来就是:“喂,PHP,那个表锁住了,你等了8秒没拿到,超时了,你自己看着办吧。”
这时候,你可以打开你的MySQL命令行,或者使用 SHOW ENGINE INNODB STATUS 来看一眼。
你会发现,有一堆事务正在等待。就像早高峰的地铁口,A挤着B,B挤着C,C挤着D,整个队伍纹丝不动。而在队伍最前面的那个A,可能正拿着一把大锤,对着一张桌子猛砸(执行长时间的更新操作)。
结论: 只要出现了等待,就是锁表。
第二章:谁是凶手?PHP代码里的“定时炸弹”
很多人会怪罪MySQL,说MySQL性能不行。其实,90%的锁表问题,根本不是MySQL太弱,而是你的PHP代码写得太“硬核”了。
让我们来看看最常见的几个导致锁表的代码坏习惯。
1. “长毛象”事务:一个事务干完所有事
想象一下,你是一个住在古代的守财奴。你手里有一把钥匙,为了安全,你把大门锁上,然后把自己关进仓库里。你这一关就是两个小时,因为你要翻箱倒柜检查每一颗珍珠。
你的朋友想进来买珍珠,对不起,门口写着“暂停营业,正在清点”。这叫长事务。
在PHP里,这通常表现为把所有的查询都塞进一个巨大的 try...catch 块里,或者是写在一个 while 循环里。
糟糕的代码示例:
try {
$pdo->beginTransaction();
// 步骤1:查询用户信息(耗时1秒)
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([1]);
$user = $stmt->fetch();
// 步骤2:执行一些复杂的计算逻辑(耗时5秒)
for ($i = 0; $i < 100000; $i++) {
$data = rand(0, 1000000);
// 仅仅是做个运算
}
// 步骤3:更新用户余额(耗时0.5秒)
$update = $pdo->prepare("UPDATE accounts SET balance = balance - ? WHERE user_id = ?");
$update->execute([$user['balance'], 1]);
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
}
专家点评:
你看,这就像是一个和尚进庙,进门就关门,把菩萨像扫了一遍,再把供品吃了一遍,最后才出门。中间来了另一个和尚,想求个签,结果发现庙里锁门了,只能在门口干瞪眼。
优化方案:
把计算逻辑移出事务!事务的核心原则是“短平快”。
// 1. 先计算
$new_balance = $user['balance'] - 10;
// 2. 先校验
if ($new_balance < 0) throw new Exception("余额不足");
// 3. 开启事务,只做数据库操作
$pdo->beginTransaction();
try {
$update = $pdo->prepare("UPDATE accounts SET balance = ? WHERE user_id = ?");
$update->execute([$new_balance, 1]);
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
2. 索引缺失:在大海捞针
有时候,锁表不是因为事务长,而是因为你把一张千万级数据的表当成小学生的作业本来写。
如果你在查询的时候,没有使用索引,MySQL引擎就得执行全表扫描。在InnoDB引擎中,扫描全表意味着每一行都要被锁定读取。如果这时候有人想更新这一行,那好,他得排队。
糟糕的代码示例:
// 假设 orders 表有 500万条数据
$stmt = $pdo->prepare("SELECT * FROM orders WHERE order_no LIKE ?");
$stmt->execute(['%20231027001%']);
专家点评:
这种 LIKE '%...%' 前缀通配符,简直就是数据库界的“黑洞”。它会让索引失效,导致引擎不得不把全表数据都读一遍。
优化方案:
加上索引!
CREATE INDEX idx_order_no ON orders(order_no);
这就好比你把原本堆在地上的书,放进了书架,想找哪本一目了然。记得写代码前先用 EXPLAIN 命令看看计划:
EXPLAIN SELECT * FROM orders WHERE order_no LIKE '20231027001%';
如果 type 列显示 ALL,那就是全表扫描;如果显示 index 或 ref,恭喜你,找到了路。
3. 死锁:两个互不相让的冤家
这是最戏剧性的一种锁表。两个事务互相等待对方释放资源,谁也不肯先松手,最后大家只能一起等超时。
场景是这样的:
事务A:更新用户1的账户,需要锁住用户1的行。
事务B:更新用户2的账户,需要锁住用户2的行。
这时候,事务C进来了。事务C想更新用户1和用户2的账户。因为事务A锁着用户1,事务B锁着用户2,事务C必须等。而A和B都在等C锁住另一个表(或者同一个表的不同行)。
糟糕的代码示例:
// 事务A (PHP脚本1)
$pdo->beginTransaction();
$pdo->exec("UPDATE accounts SET money = money - 100 WHERE user_id = 1");
$pdo->exec("UPDATE accounts SET money = money + 100 WHERE user_id = 2");
$pdo->commit();
// 事务B (PHP脚本2)
$pdo->beginTransaction();
$pdo->exec("UPDATE accounts SET money = money + 100 WHERE user_id = 2");
$pdo->exec("UPDATE accounts SET money = money - 100 WHERE user_id = 1"); // 等待A释放1
$pdo->commit();
专家点评:
这就好比你和你在街上遇到的陌生人抢着进同一个门。你站在门口左边等他先走,他站在门口右边等他先走。最后,警察来了,把你们俩都请到了派出所喝茶。
优化方案:
顺序一致性。无论是多人操作,还是程序内部,永远按照同一个顺序去更新数据。
// 规定:永远先更新 user_id 小的,再更新 user_id 大的。
// 事务A
$pdo->beginTransaction();
$pdo->exec("UPDATE accounts SET money = money - 100 WHERE user_id = 1");
$pdo->exec("UPDATE accounts SET money = money + 100 WHERE user_id = 2");
$pdo->commit();
// 事务B
$pdo->beginTransaction();
$pdo->exec("UPDATE accounts SET money = money + 100 WHERE user_id = 1"); // 等待A释放1
$pdo->exec("UPDATE accounts SET money = money - 100 WHERE user_id = 2"); // 等待A释放2
$pdo->commit();
这样,大家就按规矩办事了。
第三章:进阶战术——乐观锁与批量操作
光靠“快”和“顺序”还不够。在高并发场景下,比如抢购、秒杀,就算是神仙也守不住锁。这时候,我们需要更高级的战术。
1. 乐观锁:给自己戴个“防伪标记”
悲观锁觉得世界很危险,所以时刻锁着门。乐观锁觉得世界很美好,大家都可以进,但是我得检查是不是被修改过。
我们在数据库表里加一个 version 字段。
表结构:
CREATE TABLE products (
id INT PRIMARY KEY,
stock INT,
version INT DEFAULT 0
);
代码逻辑:
// 假设当前库存是 10,版本是 1
// 1. 先查询当前库存和版本(乐观地以为没人动过)
$stmt = $pdo->prepare("SELECT stock, version FROM products WHERE id = ?");
$stmt->execute([1]);
$product = $stmt->fetch();
// 2. 检查库存
if ($product['stock'] <= 0) {
die("卖光了!");
}
// 3. 尝试更新
// 关键点在这里:WHERE version = 当前版本号
// 如果中间别人修改了,version就变了,这个条件就不满足,更新失败。
$stmt = $pdo->prepare("UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ?");
$res = $stmt->execute([1, $product['version']]);
if ($res) {
echo "抢购成功!";
} else {
echo "抢购失败,库存被他人抢先更新了!";
}
专家点评:
这就像你在网上买东西。你提交订单时,商品描述上有个版本号V1.0。如果商家发货前改了商品描述(库存变了),变成了V2.0,你的订单就会因为版本号不匹配而被拒绝。这比一直占着位置要高效得多。
2. 批量操作:一次干完十次活
如果你有10个用户要充值,你是写10次SQL好,还是写一次 INSERT INTO ... VALUES (...), (...), (...) 好?
糟糕的循环:
foreach ($users as $user) {
$pdo->exec("INSERT INTO logs (user_id, action) VALUES ('{$user['id']}', 'login')");
// 每一次都会开启一个事务吗?不,如果你没有显式开启,每一条SQL都是一个隐式事务,都在拼命争抢锁!
}
优化方案:
// 一次性插入
$sql = "INSERT INTO logs (user_id, action) VALUES ";
$values = [];
foreach ($users as $user) {
$values[] = "({$user['id']}, 'login')";
}
$sql .= implode(',', $values);
$pdo->exec($sql);
这就像是一支送外卖的队伍,一个人一个人送太慢了,不如用一个箱子把外卖装在一起,一次性送去。数据库处理批量插入的效率通常比处理单条插入要高几十倍。
第四章:PHP连接池与架构优化——拒绝独裁
如果说代码是内功,那架构和外功就是拳脚。
1. 为什么要拒绝 mysql_pconnect?
很多老PHP程序员喜欢用 mysql_pconnect,理由是“连接数据库更快”。大错特错!
mysql_pconnect 开启的是持久连接。它的意思是:脚本运行结束,连接不关闭,而是回到连接池供下一个PHP脚本复用。
后果是什么?
如果你的PHP脚本跑得很慢,或者忘了释放连接,那么这个连接就会一直被占用,直到PHP进程结束。在高并发下,大量的持久连接会耗尽MySQL的连接数。一旦连接数耗尽,新的请求就会被拒绝。
优化方案:
现代PHP推荐使用PDO,并且关闭持久连接。
$pdo = new PDO("mysql:host=localhost;dbname=test", "user", "pass");
// 关闭持久连接
$pdo->setAttribute(PDO::ATTR_PERSISTENT, false);
或者,使用PHP-FPM。PHP-FPM本身就有连接池的概念,它会管理进程,不需要你手动去搞什么持久连接。
2. 读写分离:让主库负责写,从库负责读
如果你的网站是典型的“读多写少”的博客或内容平台,那么所有请求都打在主库上,主库迟早会累死。
优化方案:
利用PHP的代理层(如MySQL Proxy, MaxScale)或者直接在应用层做判断。
// 简单的伪代码逻辑
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 写操作
$pdo->query("UPDATE posts SET views = views + 1 WHERE id = 1"); // 假设这是错的,这是写操作,应该走主库
// 实际上应该查询配置,找到主库地址
} else {
// 读操作
// 查询配置,找到从库地址
$pdo->query("SELECT * FROM posts WHERE id = 1");
}
通过读写分离,主库只需要处理写入,压力瞬间减小一半以上,自然就不容易锁表了。
第五章:终极武器——队列与缓存
如果业务逻辑极其复杂,涉及大量的库存扣减、积分发放,数据库还是扛不住怎么办?
这时候,不要把压力给数据库,要把压力给内存。
策略: “先扣库存,后写账单”。
把核心的业务逻辑从数据库事务中剥离出来,放到消息队列(如 RabbitMQ, Kafka, Redis List)里。
流程:
- 用户点击“购买”。
- PHP先判断库存(查Redis),够不够。
- 够,PHP发一条消息给MQ:“用户A买了商品B”。
- PHP立即返回“支付成功”,不等待数据库返回。
- 后台有一个PHP消费者进程,监听MQ,拿到消息后,才去数据库里真正执行扣减库存和创建订单。
代码示例(伪代码):
// API接口
function buy() {
// 1. 检查Redis库存
$stock = $redis->get('product_stock:1001');
if ($stock <= 0) return json_encode(['code' => 500, 'msg' => '秒杀结束']);
// 2. 扣减Redis库存
$redis->decr('product_stock:1001');
// 3. 发送消息到队列,告诉后台去改数据库
$queue = new AMQPQueue('order_queue');
$queue->publish(json_encode([
'user_id' => $user['id'],
'product_id' => 1001
]));
return json_encode(['code' => 200, 'msg' => '抢购成功']);
}
专家点评:
这招叫“削峰填谷”。瞬间涌入的百万个请求,都被MQ接住了,数据库只需要慢慢消化队列里的消息。这就是架构设计的魅力——让慢的环节去处理快进来的数据。
第六章:实战中的“排雷”指南
讲了这么多理论,实战中我们怎么操作?我给大家列一个排雷清单,以后每次上线前,对着检查一遍:
- 检查长事务:
找出执行时间超过1秒的SQL,看是不是在事务里干了太多活。 - 检查索引覆盖率:
用EXPLAIN跑一下你的核心查询,保证type不是ALL,保证key字段有值。 - 检查死锁日志:
定期查看SHOW ENGINE INNODB STATUS的底部的LATEST DETECTED DEADLOCK部分。那是死锁现场直播,分析一下原因,调整更新顺序。 - 关闭不必要的持久连接:
确认你的PDO配置里没有开启ATTR_PERSISTENT。 - 批量操作代替循环单条:
插入、更新尽量使用INSERT ... ON DUPLICATE KEY UPDATE或者批量语句。 - 设置合理的超时时间:
在数据库连接配置里,给连接设置一个合理的超时时间,不要让连接无限期地挂着。
结语:与数据库和谐共处
好了,各位听众朋友,今天的讲座就要接近尾声了。
数据库锁表其实并不可怕,它就像交通堵塞一样,是高并发场景下必然出现的现象。可怕的是我们面对它时那种“无知者无畏”的态度,一边写着一团糟的代码,一边抱怨硬件不行。
作为PHP开发者,我们的职责就是:
让事务尽可能短,让索引尽可能快,让操作尽可能批量,让架构尽可能解耦。
记住,数据库不是你的仆人,它是你的合作伙伴。你越是尊重它(善待连接,优化查询),它就越不会跟你玩锁表这种幼稚的把戏。
下次当你再看到 Lock wait timeout 的报错时,别慌,深呼吸,打开 EXPLAIN,看看是谁挡了路,然后挥起我们手中的优化大锤,把它清理干净。
希望今天的课能帮到大家。如果你们还有关于锁表的具体问题,欢迎在评论区留言,我会在评论区(虽然我不一定看得到,但假装会看)解答。祝大家的代码永远流畅,业务永远爆火!
下课!