数据库里行锁到底怎么用才不出错,操作数据时那些坑你知道吗
- 问答
- 2025-12-26 19:20:26
- 3
说到数据库里的行锁,你可以把它想象成公共厕所的隔间,很多人(也就是很多程序)要同时上厕所(操作数据库里的某一行数据),行锁就是那个隔间门上的插销,当你进去之后,把插销插上(加上行锁),别人就知道里面有人了,得在外面等着,你上完厕所,打开插销(释放锁),下一个排队的人才能进去。
这个道理听起来很简单,但为什么用起来总是出错、总是踩坑呢?就是因为现实情况比上厕所复杂一万倍,你以为你锁了门,但其实可能锁了个寂寞;或者你锁了门,但钥匙被你弄丢了,导致后面所有人都堵在厕所门口憋坏了。
第一个大坑:你的“锁”根本没锁对地方
这是最常见的问题,很多人以为我执行一条更新语句不就行锁了吗?
UPDATE users SET balance = balance - 100 WHERE id = 1;
这条语句在执行的瞬间,数据库确实会给 id=1 这行数据加上行锁,防止别人同时修改它。
但问题来了,如果你的事务是这样的:
- 你先执行一条查询:
SELECT balance FROM users WHERE id = 1;(这时候你没加锁) - 你在程序里计算:newBalance = balance - 100。
- 然后你再执行上面的 UPDATE 语句。
想象一下这个场景:在你执行完 SELECT 之后、还没执行 UPDATE 之前的这个极短的空隙,另外一个程序也执行了同样的操作,它也读到了 balance=500,然后它也计算要减去100,你们两个都认为余额是500,然后都减去100,最后更新到数据库里,余额变成了400,但本来应该减去200,变成300才对,这就出错了,钱凭空少了100块。

为什么?因为你锁晚了! 你在读数据的时候,没有第一时间把“隔间门”插上,正确的做法是,在你一开始读的时候,就明确告诉数据库:“我要锁住这行数据,别人不许动”,在 MySQL 中,你可以这样写:
SELECT balance FROM users WHERE id = 1 FOR UPDATE;
这条 FOR UPDATE 语句就会在查询时直接给这行加上行锁,这样,在你的事务提交之前,其他任何想读取或修改这行数据的事务,都必须在门口乖乖排队,这就保证了数据的一致性。
第二个大坑:锁的粒度变大了——锁升级
你以为你只是锁了一行,但数据库实际上锁了一大片,这通常发生在你的查询条件没用好“索引”的情况下。
比如你有一张订单表,你想锁住用户ID为 123 的所有未支付订单:
SELECT * FROM orders WHERE user_id = 123 AND status = 'unpaid' FOR UPDATE;
user_id 和 status 字段上没有合适的索引,数据库会觉得很为难,它没办法精确定位到具体是哪几行符合条件,因为它需要逐行扫描来判断,为了防止在扫描过程中,有新的符合条件的行被插入进来(这被称为“幻读”),数据库可能会“偷懒”,直接锁住整个订单表,或者锁住一大片数据区域。

这就好比,你本来只想锁住“从里往外数第三个隔间”,但因为门上没标号(没有索引),管理员为了省事,直接把一整排隔间都锁了,导致其他想上无关隔间的人也进不去,整个系统的并发性能就急剧下降。确保你的加锁查询条件都走了正确的索引,这是避免无意中锁表的关键。
第三个大坑:死锁——你等我,我等你,大家一起死
这是行锁最经典的坑,用一个最直白的例子说明:
事务A执行:UPDATE accounts SET balance = balance - 100 WHERE id = 1; (锁住了id=1的行)
事务B执行:UPDATE accounts SET balance = balance - 200 WHERE id = 2; (锁住了id=2的行)
事情变得有趣了:
事务A又想执行:UPDATE accounts SET balance = balance + 100 WHERE id = 2; (它需要id=2的锁,但已经被B占着,于是A开始等B释放)
事务B这时执行:UPDATE accounts SET balance = balance + 200 WHERE id = 1; (它需要id=1的锁,但已经被A占着,于是B也开始等A释放)
尴尬的局面出现了:A在等B,B在等A,两个人互相瞪眼,谁也不放手,数据库等了一会儿发现这俩哥们儿没救了,就会选择一个“牺牲品”(通常是在执行语句较少、回滚成本较低的那个事务),强制让它失败并回滚,从而让另一个事务能够继续执行,这就是死锁。

怎么避免? 没有银弹,但有一些好习惯:
- 顺序访问:如果所有业务逻辑都约定好,操作多行数据时,永远按照一个固定的顺序(比如先操作id小的,再操作id大的),那么上面这个死锁场景就不会发生,因为A和B都会先去抢id=1的锁,抢到的先执行,另一个排队,就不会出现循环等待。
- 保持事务简短:事务里做的事情越少、耗时越短,它持有锁的时间就越短,与其他事务发生冲突的窗口期也就越小,千万不要在事务里执行网络调用、处理复杂的业务逻辑、或者让人工确认,这会让锁持有非常久,成为系统的瓶颈。
- 使用乐观锁:对于一些冲突不那么频繁的场景,可以不用悲观锁(就是上面说的
FOR UPDATE),而是在数据表里加一个版本号(version)字段,每次读数据时,把版本号也读出来,更新的时候,加上条件WHERE id=1 AND version=刚才读到的版本号,如果更新失败,说明这期间数据已经被别人改过了,那你就在程序里重试整个流程,这相当于把锁的判断交给了应用程序。
第四个大坑:长事务——占着茅坑不拉屎
这是一个非常可怕的问题,如果你开启了一个事务,然后在里面执行了很多操作,或者因为某些原因(比如程序bug、人为忘记)一直没有提交(commit)或回滚(rollback),那么这个事务持有的所有行锁就一直不会释放。
这就好比一个人进了隔间,然后在里面睡着了,或者玩起了手机,忘了出来,外面排队的人越聚越多,整个系统 eventually 就被拖垮了。编程时一定要确保事务有明确的开始和结束,通常使用 try-catch-finally 结构,在 finally 块里确保关闭数据库连接,释放事务。
行锁是个好东西,是保证数据不出错的核心机制,但用起来要像对待一把锋利的刀,小心谨慎:
- 该锁的时候要早锁,读的时候就要想好要不要锁。
- 锁要精准,用好索引,避免锁升级。
- 避免循环等待,尽量按顺序访问资源。
- 快锁快放,事务要短小精悍,绝不拖泥带水。
理解了这几点,你就能避开行锁的大部分坑了。
本文由酒紫萱于2025-12-26发表在笙亿网络策划,如有疑问,请联系我们。
本文链接:https://haoid.cn/wenda/68965.html
