锁之MySQL和JPA

今天

今天做了一个订单状态数据修改的需求,主要工作点是要避免并发修改导致数据的错误不一致,
当前这个模块的技术栈,需要用到MySQL行级锁和JPA的锁规范。

MySQL 行级锁

行级锁是粒度比较小的一种锁,它可以减少许多冲突,因为他只要锁住一行,而不是一张表。
(MySQL的行级锁需要使用INNODB引擎来支持,MyISAM引擎只支持表级锁)

首先回顾一下大学的基础知识:
数据库的锁,是事务在某个数据对象(如表、行等)进行操作前,对数据库发起请求,对其加锁,
使其不被其他事务所修改。
基本的锁有两种:读锁和写锁。
写锁,也称排它锁(ExclusiveLock,也称X锁),若事务对数据D加了X锁,则其他事务不能读取和修改D。
读锁,也称共享锁(ShareLock,也称S锁),若事务对数据D加了S锁,则其他事务只能对D加S锁,不能修改D。

S LOCK 在MySQL:



从上面的测试中可以发现,事务1(窗口1)加了S锁后,事务2不能加X锁和进行修改。
如果S锁一段时间后仍未释放就会超时,如下图:

X LOCK 在MySQL:

从上面的测试中可以发现,事务1加了X锁之后,事务2不能加S锁,但只要去掉
LOCK IN SHARE MODE就可以查询了。

其他的场景,可用以上SQL测试验证一下。
(注意:当用到索引时,MySql才会使用row-lock,否则table-lock。)

JPA 规范


看完了锁在MySQL的实现,那在Java中如何使用呢?这里以spring+JPA为例(为了方便,使用了starter-jpa):

JPA 锁相关规范

在spring-data-jpa中,@Lock + LockModeType 可以为某个数据操作进行锁控制,比如用X锁:

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<MemberOrder> findByOrderNo(String orderNo);

可以跑一下测试:

  1. 使用上面的窗口二先加S锁,不commit。
  2. 然后调用findByOrderNo()查询。
  3. 可以发现,程序抛出LockAcquisitionException异常,不能获取锁,等待超时,如下:
Caused by: org.hibernate.exception.LockAcquisitionException: could not extract ResultSet
at org.hibernate.dialect.MySQLDialect$1.convert(MySQLDialect.java:451)
...
Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: Lock wait timeout exceeded; try restarting transaction
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:422)
at com.mysql.jdbc.Util.handleNewInstance(Util.java:404)
at com.mysql.jdbc.Util.getInstance(Util.java:387)
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:946)
at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3878)
at com.mysql.jdbc.MysqlIO.nextRowFast(MysqlIO.java:2090)
at com.mysql.jdbc.MysqlIO.nextRow(MysqlIO.java:1964)
at com.mysql.jdbc.MysqlIO.readSingleRowSet(MysqlIO.java:3306)
atannotation com.mysql.jdbc.MysqlIO.getResultSet(MysqlIO.java:463)
at com.mysql.jdbc.MysqlIO.readResultsForQueryOrUpdate(MysqlIO.java:3040)
at com.mysql.jdbc.MysqlIO.readAllResults(MysqlIO.java:2288)
at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2681)
at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2551)
at com.mysql.jdbc.PreparedStatement.executeInternal(PreparedStatement.java:1861)
at com.mysql.jdbc.PreparedStatement.executeQuery(PreparedStatement.java:1962)
at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.extract(ResultSetReturnImpl.java:82)
... 64 more

现在来看Lock和LockModeType,Lock是spring-data的annotation,
LockModeType属于JPA规范,可作为EntityManager的方法参数传入或者使用Query.setLockMode()或TypedQuery.setLockMode()进行设置。
当然使用spring-data的话,直接使用@Lock即可,如果对细节感兴趣,可以去看spring-data的源码。
LockModeType是一个enum,它包括:

READ:同OPTIMISTIC
WRITE:同OPTIMISTIC_FORCE_INCREMENT
OPTIMISTIC:乐观锁
OPTIMISTIC_FORCE_INCREMENT:乐观锁,带版本更新
PESSIMISTIC_READ:悲观读 (MySQL对应: `lock in share mode`)
PESSIMISTIC_WRITE:悲观写 (MySQL对应: `for update`)
PESSIMISTIC_FORCE_INCREMENT:悲观写,带版本更新

上面涉及到了悲观锁和乐观锁:
所谓悲观锁,就是对数据被其他事务修改的概率保持悲观态度,
因此在处理过程中,都将数据锁定。
而乐观锁,反过来即是保持乐观态度,认为数据被其他事务修改的概率是较低的,所以在真正提交更新的时候,
才去检测数据是否冲突,实现方式可以是版本号或者时间戳,这里不再赘述。

悲观锁能严格保证数据的正确性,但凡事有利必有弊,很多事情我们都需要去平衡。
如果一个并发冲突的概率不高,而使用悲观锁,会对数据库性能开销影响比较大。
除此之外,良好的设计也可有效地避免一些并发问题。

参考资料


【1】 https://spring.io/guides/gs/accessing-data-jpa/
【2】 http://docs.oracle.com/javaee/6/tutorial/doc/gkjiu.html
【3】 https://dev.mysql.com/doc/refman/5.5/en/innodb-lock-modes.html