线程安全的数据库操作:多线程环境下如何避免数据错乱

问题从哪儿来?

你有没有遇到过这种情况:程序跑着跑着,用户的数据突然对不上了,订单少了一笔,余额算错了。查日志没报错,单测也通过,可就是出问题。这类“幽灵bug”很多时候就藏在多线程数据的交互里。

比如一个抢券系统,多个用户同时点击领取,后台用多个线程处理请求。如果没有做好线程安全控制,很可能同一张优惠券被发出去好几次。表面上看是并发性能高,实际上是在给系统埋雷。

为什么普通操作不安全?

数据库本身支持并发连接,但“能连”不等于“随便写”。典型的不安全操作长这样:

SELECT count FROM coupons WHERE id = 1;
IF count > 0:
UPDATE coupons SET count = count - 1 WHERE id = 1;

看着没问题,但在两个线程同时执行时,可能都先读到 count=1,接着都判断成立,然后都执行减1。结果本该只剩0,实际变成-1,券超发了。

加锁是最直接的办法

最简单的思路是:谁操作这条数据,先占住再说。可以用数据库的行级锁:

START TRANSACTION;
SELECT count FROM coupons WHERE id = 1 FOR UPDATE;
-- 其他逻辑判断
UPDATE coupons SET count = count - 1 WHERE id = 1;
COMMIT;

加上 FOR UPDATE 后,这行数据会被锁住,其他事务再想读这行做判断,就得等着。虽然牺牲了一点响应速度,但保证了数据不出错。

乐观锁:少锁也能稳

有些场景不适合长时间加锁,比如商品库存更新频繁,锁太久用户体验差。这时候可以用乐观锁,核心是“先查版本,更新时验证”。

给数据表加个 version 字段:

UPDATE coupons SET count = count - 1, version = version + 1 
WHERE id = 1 AND version = 3;

如果更新影响行数为0,说明 version 不对,别人已经改过了。这时候可以重试查询再操作,而不是一直等着锁。

利用数据库的原子性

很多操作其实数据库自己就能原子完成,根本不用程序层操心。比如上面的减库存,直接一条 SQL 就够:

UPDATE coupons SET count = count - 1 WHERE id = 1 AND count > 0;

这一条语句是原子的,多个线程同时执行,数据库会自己协调,最多只有一个能成功把 count 从1减到0。简单粗暴,还高效。

连接池也要注意

线程安全不只是SQL怎么写,还得看怎么连数据库。每个线程都自己 new 一个连接?那迟早内存爆掉。应该用连接池,比如 HikariCP、Druid,让线程用完归还连接,重复利用。

但要注意配置最大连接数,别让并发一上来就把数据库拖垮。有时候不是代码写得不好,是连接太多,数据库直接拒绝服务了。

实际建议

别等出问题再查。只要涉及多线程改同一条数据,一开始就按安全方式写。优先考虑数据库原生能力,比如原子更新、事务隔离级别。真要上分布式锁,也建议用 Redis 或 ZooKeeper 配合,别自己造轮子。

记住一点:用户不会关心你用了多少技术,他们只在乎自己的订单是不是正确,钱有没有扣错。稳定比炫技重要得多。