数据库初学者
介绍
数据库是高度并发的系统 。 总是有更新冲突的机会,例如当两个并发的事务尝试更新同一条记录时。 如果在任何时候只有一个数据库事务,那么所有操作将顺序执行。 当多个事务试图更新相同的数据库行时,我们将面临挑战,因为我们仍然必须确保一致的数据状态转换。
SQL标准定义了三个一致性异常(现象) :
- 脏读 ,通过提交读,可重复读和可序列化隔离级别防止
- 不可重复读取 ,由可重复读取和可序列化隔离级别阻止
- 幻像读取 ,可序列化隔离级别阻止
鲜为人知的现象是丢失的更新异常,这就是我们将在本文中讨论的内容。
隔离度
大多数数据库系统将“已提交读”作为默认隔离级别(MySQL使用“可重复读”代替)。 选择隔离级别是为了找到满足我们当前应用程序需求的一致性和可伸缩性之间的平衡。
以下所有示例都将在PostgreSQL 9.3上运行。 其他数据库系统可能会根据其特定的ACID实现而有所不同。
PostgreSQL同时使用锁和MVCC(多版本并发控制) 。 在MVCC中,读取和写入锁定没有冲突,因此读取不会阻止写入,写入也不会阻止读取。
由于大多数应用程序都使用默认的隔离级别,因此了解“读取已提交”特征非常重要:
- 查询仅查看查询开始之前已提交的数据,以及当前事务未提交的更改
- 查询执行期间提交的并发更改对当前查询不可见
- UPDATE / DELETE语句使用锁来防止并发修改
如果两个事务尝试更新同一行,则第二个事务必须等待第一个事务提交或回滚,并且如果第一个事务已提交,则必须重新评估第二个事务DML WHERE子句以查看是否匹配仍然相关。
在此示例中,Bob的UPDATE必须等待Alice的事务结束(提交/回滚)才能继续进行。
与其他更严格的隔离级别相比,“已提交读”容纳更多的并发事务,但是较少的锁定导致丢失更新的机会更大。
更新丢失
如果两个事务正在更新同一行的不同列,则没有冲突。 第二次更新将阻塞,直到提交第一个事务,并且最终结果将反映这两个更新更改。
如果两个事务要更改相同的列,则第二个事务将覆盖第一个事务,因此失去了第一个事务的更新。
因此,当用户覆盖当前数据库状态而没有意识到在数据加载到更新之间其他人对其进行了更改时,更新将丢失。
在此示例中,Bob不知道Alice刚刚将数量从7更改为6,因此她的UPDATE被Bob的更改覆盖。
典型的查找-修改-刷新ORM策略
Hibernate(像任何其他ORM工具一样)自动将实体状态转换转换为SQL查询 。 您首先加载一个实体,对其进行更改,然后让Hibernate刷新机制将所有更改与数据库同步。
public Product incrementLikes(Long id) {
Product product = entityManager.find(Product.class, id);
product.incrementLikes();
return product;
}
public Product setProductQuantity(Long id, Long quantity) {
Product product = entityManager.find(Product.class, id);
product.setQuantity(quantity);
return product;
}
正如我已经指出的那样,所有UPDATE语句都获得写锁,即使是在“读提交”隔离中也是如此。 持久性上下文后写策略旨在减少锁定保持时间间隔,但是读操作和写操作之间的时间间隔越长,进入丢失更新情况的机会就越大。
Hibernate在UPDATE语句中包括所有行列。 可以更改此策略以仅包含脏属性(通过@DynamicUpdate批注),但是参考文档警告我们其有效性:
尽管这些设置在某些情况下可以提高性能,但实际上在其他情况下却可以降低性能。
因此,让我们看看Alice和Bob如何使用ORM框架同时更新同一产品:
爱丽丝 | 鲍勃 |
---|---|
store =#开始; store =#选择*来自产品 WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 5 | 7 (1行) | store =#开始; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 5 | 7 (1行) |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(6,7) WHERE ID = 1; | |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(5,10) WHERE ID = 1; | |
store =#COMMIT; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 6 | 7 (1行) | |
store =#COMMIT; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 5 | 10 (1行) | |
store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 |
再一次,在鲍勃不知道他重写了她的更改的情况下,爱丽丝的更新丢失了。 我们应该始终防止数据完整性异常,所以让我们看看如何克服这种现象。
可重复读
使用重复读取(以及提供更严格隔离级别的可序列化)可以防止并发数据库事务中的更新丢失。
爱丽丝 | 鲍勃 |
---|---|
store =#开始; store =#SET事务隔离级别可重复读取; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 5 | 7 (1行) | store =#开始; store =#SET事务隔离级别可重复读取; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 5 | 7 (1行) |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(6,7)WHERE ID = 1; | |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(5,10)WHERE ID = 1; | |
store =#COMMIT; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 6 | 7 (1行) | |
错误:由于并发更新,无法序列化访问 store =#SELECT * FROM PRODUCT WHERE ID = 1; 错误:当前事务中止,命令被忽略,直到事务块结束 (1行) |
这次,Bob无法覆盖Alice的更改,并且他的交易被中止。 在“可重复读取”中,查询将看到当前事务开始时的数据快照。 其他并发事务提交的更改对当前事务不可见。
如果两个事务试图修改同一记录,则第二个事务将等待第一个事务提交或回滚。 如果第一个事务提交,则第二个事务必须中止以防止丢失更新。
选择更新
另一种解决方案是将FOR UPDATE与默认的Read Committed隔离级别一起使用。 该锁定子句获得与UPDATE和DELETE语句相同的写锁定。
爱丽丝 | 鲍勃 |
---|---|
store =#开始; store =#SELECT * FROM PRODUCT WHERE ID = 1 FOR UPDATE; ID | 赞| 数量 --- + ——- + ------ 1 | 5 | 7 (1行) | store =#开始; store =#SELECT * FROM PRODUCT WHERE ID = 1 FOR UPDATE; |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(6,7)WHERE ID = 1; store =#COMMIT; store =#SELECT * FROM PRODUCT WHERE ID = 1; ID | 赞| 数量 --- + ——- + ------ 1 | 6 | 7 (1行) | |
id | 喜欢| 数量 --- + ——- + ------ 1 | 6 | 7 (1行)store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(6,10)WHERE ID = 1; 更新1 store =#COMMIT; 承诺 store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 --- + ——- + ------ 1 | 6 | 10 (1列) |
Bob无法继续执行SELECT语句,因为Alice已经获得了同一行的写锁。 鲍勃将不得不等待爱丽丝结束交易,并且当鲍勃的SELECT解除阻止时,他将自动看到她的更改,因此爱丽丝的UPDATE不会丢失。
两个事务都应使用FOR UPDATE锁定。 如果第一个事务没有获得写锁,丢失的更新仍然可能发生。
爱丽丝 | 鲍勃 |
---|---|
store =#开始; store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 --- + ——- + ------ 1 | 5 | 7 (1列) | |
store =#开始; store =#选择*从产品ID = 1进行更新 id | 喜欢| 数量 --- + ——- + ------ 1 | 5 | 7 (1列) | |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(6,7)WHERE ID = 1; | |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY)=(6,10)WHERE ID = 1; store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 --- + ——- + ------ 1 | 6 | 10 (1列) store =#COMMIT; | |
store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 store =#COMMIT; | |
store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 |
爱丽丝的UPDATE被阻止,直到鲍勃在当前事务结束时释放写锁为止。 但是Alice的持久性上下文使用的是陈旧的实体快照,因此她覆盖了Bob的更改,从而导致另一种丢失更新的情况。
乐观锁
我最喜欢的方法是用乐观锁定机制代替悲观锁定。 与MVCC一样,乐观锁定定义了版本控制并发控制模型,该模型可以在不获取其他数据库写锁定的情况下工作。
产品表还将包括一个版本列,该列可防止旧数据快照覆盖最新数据。
爱丽丝 | 鲍勃 |
---|---|
store =#开始; 开始 store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 版 -+ ——- + -——- +- 1 | 5 | 7 | 2 (1列) | store =#开始; 开始 store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 版 -+ ——- + -——- +- 1 | 5 | 7 | 2 (1列) |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY,VERSION)=(6,7,3)WHERE(ID,VERSION)=(1,2); 更新1 | |
store =#UPDATE PRODUCT SET(LIKES,QUANTITY,VERSION)=(5,10,3)WHERE(ID,VERSION)=(1,2); | |
store =#COMMIT; store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 版 -+ ——- + -——- +- 1 | 6 | 7 | 3 (1列) | |
更新0 store =#COMMIT; store =#SELECT * FROM PRODUCT WHERE ID = 1; id | 喜欢| 数量 版 -+ ——- + -——- +- 1 | 6 | 7 | 3 (1列) |
每个UPDATE都会将加载时版本放入WHERE子句中,假设自从数据库检索到该行以来,没有人更改过该行。 如果其他某个事务设法提交较新的实体版本,则UPDATE WHERE子句将不再与任何行匹配,因此可以防止丢失更新。
Hibernate使用PreparedStatement#executeUpdate结果检查更新的行数。 如果没有匹配的行,则抛出StaleObjectStateException (使用Hibernate API时)或OptimisticLockException (使用JPA时)。
与“可重复读取”一样,就原子性保证而言,当前事务和持久性上下文将中止。
结论
除非您计划防止此类情况,否则可能会丢失更新。 除了乐观锁定之外,当SELECT和UPDATE语句都在同一物理事务中执行时,所有悲观锁定方法仅在同一数据库事务的范围内有效。
在我的下一篇文章中,我将解释为什么在使用应用程序级事务时乐观锁定是唯一可行的解决方案,就像大多数Web应用程序一样。
数据库初学者