在项目中,遇到个并发问题,虽然不是个大问题,但确实是之前没考虑到的,具体体现类似于余额扣减,多个客户端同时访问web接口,导致临界数据计算出现异常。此时就代表着我可能就碰到了一个并发问题了。

这里资料主要是参考了:

知乎上的回答:高并发下怎么做余额扣减?

金融系统中高并发下投资余额扣减问题的解决思路

通过知乎上的一些回答,第一个让我get到需要改正的点的是,条件判断和临界参数的比较(与0比较,或是余额比较),都是可以直接交给数据库去做的,这样就可以利用到MySQL自身的锁机制来帮助我们处理并发。

MySQL锁

MySQL中一般常见的锁,表锁和行锁,根据mysql引擎使用的不同,能够使用的锁也不同,例如行锁适用于InnoDB,这里也只是介绍行锁,其并发度也是最高的。

这里介绍InnoDB的行锁模式及加锁方法。

1
2
3
4
5
   共享锁(s):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
排他锁(X):允许获取排他锁的事务更新数据,阻止其他事务取得相同的数据集共享读锁和排他写锁。
另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。
意向共享锁(IS):事务打算给数据行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
意向排他锁(IX):事务打算给数据行加排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。

​ 如果一个事务请求的锁模式与当前的锁兼容,InnoDB就请求的锁授予该事务;反之,如果两者两者不兼容,该事务就要等待锁释放。

​ 意向锁是InnoDB自动加的,不需用户干预。对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及及数据集加排他锁(X);对于普通SELECT语句,InnoDB不会加任何锁;事务可以通过以下语句显示给记录集加共享锁或排锁。

共享锁(S):SELECT * FROM table_name WHERE … LOCK IN SHARE MODE

排他锁(X):SELECT * FROM table_name WHERE … FOR UPDATE

用SELECT … IN SHARE MODE获得共享锁,主要用在需要数据依存关系时确认某行记录是否存在,并确保没有人对这个记录进行UPDATE或者DELETE操作。但是如果当前事务也需要对该记录进行更新操作,则很有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用SELECT … FOR UPDATE方式获取排他锁。

1
2
3
4
5
6
7
8
// Go中显示标明需要获取排他锁的SQL语句, ·WAIT 2·表示其他SQL请求等待2s后timeout,NOWAIT表示不等待
tx := Db.Set("gorm:query_option", " FOR UPDATE WAIT 2")

begin;
// Add extra SQL option for selecting SQL
db.Set("gorm:query_option", "FOR UPDATE").First(&user, 10)
// SELECT * FROM users WHERE id = 10 FOR UPDATE;
commit;

需要注意的是:for update仅适用于,且必须在事务块(BEGIN/COMMIT)中才能生效。只有通过 索引 条件检索数据,InnoDB才会使用行级锁,否则,InnoDB将使用表锁!

使用SELECT ... FOR UPDATE时,只有明确指定主键,MySQL才会执行Row Lock(只锁住被选取的数据),否则MySQL将获取表锁。

结合前面的来说,使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。且其在查询时,若没有查询到数据,将不会产生锁。

若在查询条件中,使用不等于<>,模糊查询like等函数还是会产生表锁的。

总得来说,这里MySQL使用的是悲观锁,对于悲观锁的理解是,读写都来一把锁,将数据进行锁定;而乐观锁呢,大意就是不需要锁进行操作,而是通过合理的逻辑,去规避同时操作数据所出现的问题,以不加锁的方式实现同一时间仅一次操作数据的操作能够正常执行。

1
2
3
乐观所和悲观锁策略
悲观锁:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续 。
乐观所:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新,一般在悲观锁的等待时间过长而不能接受时我们才会选择乐观锁。

参考:mysql事务,select for update,及数据的一致性处理

数据一致性

假设有A、B两个用户同时各购买一件 id=1 的商品,用户A获取到的库存量为 1000,用户B获取到的库存量也为 1000,用户A完成购买后修改该商品的库存量为 999,用户B完成购买后修改该商品的库存量为 999,此时库存量数据产生了不一致。

有两种解决方案:

悲观锁方案:每次获取商品时,对该商品加排他锁。也就是在用户A获取获取 id=1 的商品信息时对该行记录加锁,期间其他用户阻塞等待访问该记录。悲观锁适合写入频繁的场景。

1
2
3
4
begin;
select * from goods where id = 1 for update;
update goods set stock = stock - 1 where id = 1;
commit;

乐观锁方案:每次获取商品时,不对该商品加锁。在更新数据的时候需要比较程序中的库存量与数据库中的库存量是否相等,如果相等则进行更新,反之程序重新获取库存量,再次进行比较,直到两个库存量的数值相等才进行数据更新。乐观锁适合读取频繁的场景。

1
2
3
4
5
6
7
#不加锁获取 id=1 的商品对象
select * from goods where id = 1

begin;
#更新 stock 值,这里需要注意 where 条件 “stock = cur_stock”,只有程序中获取到的库存量与数据库中的库存量相等才执行更新
update goods set stock = stock - 1 where id = 1 and stock = cur_stock;
commit;

如果我们需要设计一个商城系统,该选择以上的哪种方案呢?

查询商品的频率比下单支付的频次高,基于以上我可能会优先考虑第二种方案(当然还有其他的方案,这里只考虑以上两种方案)。

数据库:MySQL 中 “select … for update” 排他锁分析

too many open file

在服务器端时,在正常运行的过程中,总会出现socket: too many open file,然后当次HTTP请求就会出现访问错误,这是由于linux对程序打开文件的限制,使用命令ulimit -a,默认open file (-n) 1024,解决方法是ulimit -n 8192,将支持打开的文件数量调大些。

这里还是服务端在请求产生后,资源没有及时释放,我使用resty HTTP库时,会比较快的出现这个问题,我换成使用原生Golang库发起请求时,这个问题会减轻很多,这里我对这个库的资源回收方面的处理表示一些不好的意向。另外viper库竟然不支持并发安全,这也是让我没有预料到的。