在实际工作中经常遇到对账户的操作(账户充值和账户消费),处理的逻辑如下:
// 1 查询账户当前的金额
// 2 根据操作,计算操作后的金额
// 3 更新账户的金额
然而,在实际中经常会有并发操作的问题,下面通过在数据中执行SQL的方式,模拟下不做并发处理的情况:
数据库是MySQL,隔离级别采用默认的可重复读,表为t_money,只有两列:id、money,只有一条记录id=1, money=1000。分别起两个客户端,模拟并发操作的行为:
- 事务1,账户消费100元
- 事务2,账户充值200元
序号 | 事务1 | 事务2 |
---|---|---|
1 | start transaction; | |
2 | start transaction; | |
3 | select * from t_money where id=1; | |
4 | select * from t_money wehre id=1; | |
5 | update t_money set money=900 where id=1; | |
6 | update t_money set money=1200 where id=1; (不能执行,被阻塞) | |
7 | select * from t_money where id=1; | |
8 | commit; | (事务1执行commit后,被阻塞的update执行) |
9 | select * from t_money where id=1; | select * from t_money where id=1; |
10 | commit; | |
11 | select * from t_money where id=1; | select * from t_money where id=1; |
按照上面的步骤执行完成后,11步查出来账户id=1的money=1200。
按照业务的逻辑,消费和充值后,账户的金额应该为1100,而系统中id=1的账户金额居然为1200,这是绝对不能接受的!
解决方案
1. 利用MySQL的当前读
将更新金额的语句,使用:
update t_money set money=money-100 where id=1;
update会使用“当前读”,可以读取到其它事物未提交的数据。当前读遇到其它事务的写操作时,会被阻塞,引起当前读的语句:
select ... for update;
select ... lock in share mode;
update
delete
insert
2. redis并发锁
也就是,操作前要获得锁,操作完成释放锁;没有获得锁,不允许进行操作,直接返回并发错误。
在实际系统中,往往是分布式部署的,那么就需要加分布式锁。最容易想到(本人)的就是使用redis,在redis中使用setnx,伪代码如下:
if(redis.setnx(id)){
// 加锁成功
// 账户操作
} else {
// 返回并发错误,由调用者处理后续逻辑(重试等)
}
2. 优雅的redis并发锁
在方案1中,在加锁失败后,直接返回并发异常,调用方需要重试。实际上,第一次请求时,虽然不能获得锁,但是可能在1s之后就可以获得锁了,我们何不如稍微等待下再重试呢?
更加优雅的加锁,伪代码:
if (redis.setnx(id)) {
// 加锁成功
// 账户操作
} else {
// 第一次加锁失败
Thread.sleep(1000); // 等待1s,也可以等待并指定多次重试
if (redis.setnx(id)) {
// 账户操作
} else {
// 返回并发错误
}
}
对于redis实现并发锁,有很多可以研究的细节,比如:setnx成功后,系统挂了,后续加锁就永远不能成功了,该如何处理?更多细节,可以看看他人是如何用redis实现分布式并发锁的。