在不少异常状况下,好比高并发、网络糟糕的时候,数据库里偶尔会出现重复的记录。mysql
假如如今有一张书籍表,结构相似这样redis
+----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+
在异常状况下,可能会出现下面这样的记录sql
+----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 2 | 人类简史 | | 3 | 人类简史 | +----+--------------+
可是,想了想,本身在处理相关数据的时候也加了判重的相关逻辑,好比,新增时当图书 name 相同时,会提示图书重复而返回。数据库
初次遇到这个状况的时候,感受有点摸不着头脑,后面想了想,仍是理清了,其实这和数据库的事务隔离级别有必定关系。json
先简单说下数据库事务的 4 个隔离级别,而后重现下上述问题,最后说说解决办法。后端
顾名思义,当事务隔离级别处于这个设置的时候,不一样事务能读取其它事务中未提交的数据。网络
便于说明,我开了两个客户端(A 以及 B),并设置各自的隔离级别为未提交读。(并无全局设置)session
设置隔离级别命令并发
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
好了,开始。高并发
Client A
mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; Query OK, 0 rows affected (0.00 sec) mysql> SELECT @@session.tx_isolation; +------------------------+ | @@session.tx_isolation | +------------------------+ | READ-UNCOMMITTED | +------------------------+ 1 row in set (0.00 sec) mysql> start transaction; Query OK, 0 rows affected (0.01 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec) mysql> insert into books(name) values('人类简史'); Query OK, 1 row affected (0.01 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 4 | 人类简史 | +----+--------------+ 2 rows in set (0.00 sec)
当 A 中的事务没有关闭的时候,咱们去 B 中看下数据
Client B
mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; Query OK, 0 rows affected (0.00 sec) mysql> SELECT @@session.tx_isolation; +------------------------+ | @@session.tx_isolation | +------------------------+ | READ-UNCOMMITTED | +------------------------+ 1 row in set (0.00 sec) mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 4 | 人类简史 | +----+--------------+ 2 rows in set (0.00 sec)
B 中能够读取 A 未提交的数据,所谓未提交读就是这样。
最后,记得把各个事务提交。
Client A & Client B
mysql> commit;
不能事务能够读取其它事务中已经提交的数据。
篇幅问题,这里我就不贴出设置隔离级别的语句,测试某个隔离级别的时候,默认已经设置好该级别。
Client A
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec) mysql> insert into books(name) values('人类简史'); Query OK, 1 row affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 5 | 人类简史 | +----+--------------+ 2 rows in set (0.00 sec)
A 没提交,在 B 里面去看下数据
Client B
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec)
和预期同样,A 中未提交的数据在 B 中看不到。
A 中提交事务
Client A
mysql> commit;
在 B 中看下
Client B
mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 5 | 人类简史 | +----+--------------+ 2 rows in set (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec)
B 中能看到 A 中提交的数据。
细心的朋友可能会发现一个问题,那就是在 B 中的同一个事务读同一个表,获得的结果却不一致,开始只有 1 条,后面有 2 条,而若是没有这个问题的话,也就是可重复读了。
咱们来验证下
Client A
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec) mysql> insert into books(name) values('人类简史'); Query OK, 1 row affected (0.01 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 6 | 人类简史 | +----+--------------+ 2 rows in set (0.00 sec)
Client B
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec)
Client A
mysql> commit Query OK, 0 rows affected (0.00 sec)
Client B
mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec)
和预期一致。B 中事务没有受到 A 中事务的提交影响,读取的数据和事务刚开始的时候一致,books 中都只有一条数据,这就是可重复读。
固然,B 在本身的事务中作修改,确定是可见的。
Client B
mysql> insert into books(name) value ('时间简史'); Query OK, 1 row affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 8 | 时间简史 | +----+--------------+ 2 rows in set (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec)
这是隔离级别最严格的一级,在该级别中,不一样事务中的读写会相互阻塞。
Client A
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec)
当 A 未提交的时候在 B 中对同一个表进行写
Client B
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books; +----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+ 1 row in set (0.00 sec) mysql> insert into books(name) value ('人类简史'); ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
因为不一样事务中的读写相互阻塞,因此出现了上面超时的状况。
若是 A 中提交事务
Client A
mysql> commit; Query OK, 0 rows affected (0.00 sec)
那么在 B 中就能正常写了
Client B
mysql> insert into books(name) value ('人类简史'); Query OK, 1 row affected (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.01 sec)
同理,在 A 中开启事务并向 books 中插入一条记录后不提交,B 中开启事务并对该表进行读操做,也会超时。当 A 中的事务提交后,B 中对 books 的读操做就没有问题了。
因为 MySQL 的 Innodb 的默认事务隔离级别为可重复读,也就致使了判重逻辑可能会出现问题,咱们来重现一下。
如今,数据库的数据是这样的
+----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | +----+--------------+
后端逻辑相似这样的
try: book_name = '人类简史' book = get_by_name(book_name) if book: raise Exception(f'图书 {book_name} 已存在') # 新增操做 # 其它操做 db.session.commit() return {'success': True} except Exception as e: db.session.rollback() return {'success': False, 'msg': f'新增图书失败 {e}'}
当两个用户输入书名「人类简史」并提交后,同时有两个线程执行这段逻辑,也就至关于上面两个客户端同时开启了事务,咱们以这两个客户端来讲明问题
Client A
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books where name = '人类简史'; Empty set (0.00 sec) mysql> insert into books(name) values('人类简史'); Query OK, 1 row affected (0.00 sec)
A 中检测图书不存在,而后插入,可是因为「其它操做」因为网络或者其它缘由太费时间,致使事务提交延迟。
这时在 B 中执行相似操做
Client B
mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from books where name = '人类简史'; Empty set (0.00 sec) mysql> insert into books(name) values('人类简史'); Query OK, 1 row affected (0.00 sec)
因为事务隔离级别是可重复读的,B 中没法读取 A 中未提交的数据,因此判重逻辑顺利经过,也插入了同一本书。(也就是说隔离级别在提交读及以上都有可能出现这个问题)
最后 A 和 B 都提交后
Client A & Clinet B
mysql> commit; Query OK, 0 rows affected (0.01 sec)
就出现了重复记录了
+----+--------------+ | id | name | +----+--------------+ | 1 | 世界简史 | | 12 | 人类简史 | | 13 | 人类简史 | +----+--------------+
从底层进行限制,对 name 添加惟一索引后,插入重复记录会报错,简单粗暴的解决了这个问题。
加惟一索引能解决,可是总以为代码不够完整,其实在代码层面也能够解决这个问题。
若是咱们在接收请求的时候若是碰到关键参数相同的请求,咱们能够直接拒绝,返回相似「操做进行中」的响应,这样也就从源头上解决了这个问题。
实现上面的思路也很简单,借助 redis 的 setnx 便可。
book_name = request.form.get('book_name', '') if not book_name: reutrn json.dumps({'success': False, 'msg': '请填写书名'}) redis_key = f'add_book_{book_name}' set_res = redis_client.setnx(redis_key, 1) if not set_res: reutrn json.dumps({'success': False, 'msg': '操做进行中'}) add_res = add_book(book_name) # 添加操做 redis_client.delete(redis_key) return json.dumps(add_res)
若是相似场景比较多,能够考虑把 redis 的操做封装成一个装饰器,让代码能复用起来,这里再也不赘述。
因为数据库隔离级别的缘由,一些数据就算是逻辑上进行防重了,也有可能出现重复记录。解决这个问题,能够在数据库层面加惟一索引解决,也能够在代码层面进行解决。
原文博客:www.kevinbai.com
关注「小小后端」公众号,更多干货等着你喔!