数据库存数据时,逻辑上防重了为啥还会出现重复记录?

在很多异常情况下,比如高并发、网络糟糕的时候,数据库里偶尔会出现重复的记录。

假如现在有一张书籍表,结构类似这样

+----+--------------+
| id | name         |
+----+--------------+
|  1 | 世界简史     |
+----+--------------+

在异常情况下,可能会出现下面这样的记录

+----+--------------+
| id | name         |
+----+--------------+
|  1 | 世界简史     |
|  2 | 人类简史     |
|  3 | 人类简史     |
+----+--------------+

但是,想了想,自己在处理相关数据的时候也加了判重的相关逻辑,比如,新增时当图书 name 相同时,会提示图书重复而返回。

初次遇到这个情况的时候,感觉有点摸不着头脑,后面想了想,还是理清了,其实这和数据库的事务隔离级别有一定关系。

先简单说下数据库事务的 4 个隔离级别,然后重现下上述问题,最后说说解决办法。

1 数据库事务的 4 个隔离级别

1.1 未提交读

顾名思义,当事务隔离级别处于这个设置的时候,不同事务能读取其它事务中未提交的数据。

便于说明,我开了两个客户端(A 以及 B),并设置各自的隔离级别为未提交读。(并没有全局设置)

设置隔离级别命令

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;

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.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 中提交的数据。

1.3 可重复读

细心的朋友可能会发现一个问题,那就是在 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)

1.4 串行化

这是隔离级别最严格的一级,在该级别中,不同事务中的读写会相互阻塞。

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 的读操作就没有问题了。

2 重现问题

由于 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 | 人类简史     |
+----+--------------+

3 怎么解决

3.1 数据库层面

从底层进行限制,对 name 添加唯一索引后,插入重复记录会报错,简单粗暴的解决了这个问题。

3.2 代码层面

加唯一索引能解决,但是总觉得代码不够完整,其实在代码层面也可以解决这个问题。

如果我们在接收请求的时候如果碰到关键参数相同的请求,我们可以直接拒绝,返回类似「操作进行中」的响应,这样也就从源头上解决了这个问题。

实现上面的思路也很简单,借助 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 的操作封装成一个装饰器,让代码能复用起来,这里不再赘述。

4 小结

由于数据库隔离级别的原因,一些数据就算是逻辑上进行防重了,也有可能出现重复记录。解决这个问题,可以在数据库层面加唯一索引解决,也可以在代码层面进行解决。

原文链接:http://www.kevinbai.com/articles/8.html

关注「小小后端」公众号,更多干货等着你喔!

原文地址:https://www.cnblogs.com/kevinbai/p/11827196.html

时间: 2024-08-05 01:31:43

数据库存数据时,逻辑上防重了为啥还会出现重复记录?的相关文章

SQL 数据库存数据排序问题

数据库存中的数据: 110111213141516171823 按照order by HBH 来排序的结果,但这不是我期望的结果 原因:排序的字段不是,而是文本,需要强制转换为数值才行 CAST(GH.HH as int) SortHH order by GH.HH 12345678910111213

redis 几种数据类型往数据库存数据和取数据的帮助类

package com.fndsoft.bcis.utils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.*; import org.springframework.stereotype.Service; import java.util.*; /** * redis缓存帮助类 * Created by DELL on 2016

面对数据丢失、数据错误、业务逻辑发生变化时,可以这么解决。

根据福克斯新闻在20日的报道,美国田纳西州一名14岁男孩Jackson成功在家中打造出核融合实验的小型聚变反应器,成功结合2个氘原子.释出一颗中子. 关键是,Jackson所需的零件是从网上购买,或自己改装的.而且仅用一年时间就打造出了反应器.美媒称,他可能打破了全球最年轻核科学家Taylor Wilson的纪录,是年纪最小的完成制作了核聚变反应器的"科学家". 嗯,还是别人家的孩子,惹不起惹不起!然而没有对比就没有伤害,说出来不怕你们笑话我(们): 面对数据丢失的我 我在公司负责统计

由于改 UOM conversion 导致库存数量和财务上的数据错误

轻易改变 UOM conversion 会导致库存数量混乱, 也会造成财务上的数据错误. 我们这里做一个 case 来具体分析一下. 1. 开始 Carton 和 Each 的比例是 1 : 1. 2. 我们创建一个PO, ship to W1, 是一个WMS Org. Item 是 lot control 的. UOM 使用 Carton, 不用这个 item 的 Primary UOM. 这里我们注意单价是15,  因为在定义 item 的时候, 1 个 Each 单价是15, 再根据单位转

批量更新数据不同状态时 ,1.在更新前检测 2.更新后执行自己的逻辑

今天在项目中做盘点管理,盘点单据有 编辑->提交->审批->撤销->退回->开盘->盘毕等状态.根据操作对盘点单据会不停的更新其状态.一般做法是根据这5.6种状态,编写5,6中ServiceImpl方法 分别去Update.若要是批量操作则会加上for循环,代码块冗余过多.自己便将Update抽离出来.抽离出来后,面临这一些问题.就是在有些状态更新操作时,需要做验证(如:退回只能操作审批状态.开盘只能操作审批状态).这个时候就需要在update之前需要判断,看其当前状态

android 数据库存取图片

Android数据库中存取图片通常使用两种方式,一种是保存图片所在路径,二是将图片以二进制的形式存储(sqlite3支持BLOB数据类型).对于两种方法的使用,好像第二种方法不如第一种方法更受程序员欢迎,他们认为,在很多数据库语言里,处理大字段都是不容易的,像图片这样的文件放在数据库里会有问题:对数据库的读写速度永远赶不上文件系统的处理速度,使数据库变得巨大:但也有很多人认为像图片这样的数据存放在数据库中也有好处:易于备份,且备份速度绝对比备份文件快,比较容易数据迁移等等.其实这两种方法都有优缺

数据倾斜发生时的现象

调优概述 有的时候,我们可能会遇到大数据计算中一个最棘手的问题——数据倾斜,此时Spark作业的性能会比期望差很多.数据倾斜调优,就是使用各种技术方案解决不同类型的数据倾斜问题,以保证Spark作业的性能. 数据倾斜发生时的现象 绝大多数task执行得都非常快,但个别task执行极慢.比如,总共有1000个task,997个task都在1分钟之内执行完了,但是剩余两三个task却要一两个小时.这种情况很常见. 原本能够正常执行的Spark作业,某天突然报出OOM(内存溢出)异常,观察异常栈,是我

double类型的数据在计算时准确性的错误

浮点数 浮点数是属于有理数中某特定子集的数的数字表示,在计算机中用以近似表示任意某个实数.具体的说,这个实数由一个整数或定点数(即尾数)乘以某个基数(计算机中通常是2)的整数次幂得到,这种表示方法类似于基数为10的科学记数法. 浮点计算是指浮点数参与的运算,这种运算通常伴随着因为无法精确表示而进行的近似或舍入. 一个浮点数a由两个数m和e来表示:a = m × b^e.在任意一个这样的系统中,我们选择一个基数b(记数系统的基)和精度p(即使用多少位来存储).m(即尾数)是形如±d.ddd...d

典型问题分析4—StaticLinkList中数据元素删除时的效率问题

StaticLinkList中数据元素删除时的效率问题 void destroy(Node* pn) { SNode* space = reinterpret_cast<SNode*>(m_space); SNode* psn = dynamic_cast<SNode*>(pn);//从父类指针,转换为子类指针,直接调用dynameic_cast for(int i=0; i<N; i++) { if(psn == (space + i)) { m_used[i] = 0;