MySQL存储过程之事务管理
ACID:Atomic、Consistent、Isolated、Durable
存储程序提供了一个绝佳的机制来定义、封装和管理事务。
1,MySQL的事务支持
MySQL的事务支持不是绑定在MySQL服务器本身,而是与存储引擎相关:
Java代码MyISAM:不支持事务,用于只读程序提高性能InnoDB:支持ACID事务、行级锁、并发BerkeleyDB:支持事务
隔离级别:
隔离级别决定了一个session中的事务可能对另一个session的影响、并发session对数据库的操作、一个session中所见数据的一致性
ANSI标准定义了4个隔离级别,MySQL的InnoDB都支持:
Java代码READUNCOMMITTED:最低级别的隔离,通常又称为dirtyread,它允许一个事务读取还没commit的数据,这样可能会提高性能,但是dirtyread可能不是我们想要的READCOMMITTED:在一个事务中只允许已经commit的记录可见,如果session中select还在查询中,另一session此时insert一条记录,则新添加的数据不可见REPEATABLEREAD:在一个事务开始后,其他session对数据库的修改在本事务中不可见,直到本事务commit或rollback。在一个事务中重复select的结果一样,除非本事务中update数据库。SERIALIZABLE:最高级别的隔离,只允许事务串行执行。为了达到此目的,数据库会锁住每行已经读取的记录,其他session不能修改数据直到前一事务结束,事务commit或取消时才释放锁。
可以使用如下语句设置MySQL的session隔离级别:
Java代码SETTRANSACTIONISOLATIONLEVEL{READUNCOMMITTED|READCOMMITTED|REPEATABLEREAD|SERIALIZABLE}
MySQL默认的隔离级别是REPEATABLE READ,在设置隔离级别为READ UNCOMMITTED或SERIALIZABLE时要小心,READ UNCOMMITTED会导致数据完整性的严重问题,而SERIALIZABLE会导致性能问题并增加死锁的机率
事务管理语句:
Java代码STARTTRANSACTION:开始事务,autocommit设为0,如果已经有一个事务在运行,则会触发一个隐藏的COMMITCOMMIT:提交事务,保存更改,释放锁ROLLBACK:回滚本事务对数据库的所有更改,然后结束事务,释放锁SAVEPOINTsavepoint_name:创建一个savepoint识别符来ROLLBACKTOSAVEPOINTROLLBACKTOSAVEPOINTsavepoint_name:回滚到从savepoint_name开始对数据库的所有更改,这样就允许回滚事务中的一部分,保证更改的一个子集被提交SETTRANSACTION:允许设置事务的隔离级别LOCKTABLES:允许显式的锁住一个或多个table,会隐式的关闭当前打开的事务,建议在执行LOCKTABLES语句之前显式的commit或rollback。我们一般所以一般在事务代码里不会使用LOCKTABLES
2,定义事务
MySQL默认的行为是在每条SQL语句执行后执行一个COMMIT语句,从而有效的将每条语句独立为一个事务。
在复杂的应用场景下这种方式就不能满足需求了。
为了打开事务,允许在COMMIT和ROLLBACK之前多条语句被执行,我们需要做以下两步:
1, 设置MySQL的autocommit属性为0,默认为1
2,使用START TRANSACTION语句显式的打开一个事务
如果已经打开一个事务,则SET autocommit=0不会起作用,因为START TRANSACTION会隐式的提交session中所有当前的更改,结束已有的事务,并打开一个新的事务。
使用SET AUTOCOMMIT语句的存储过程例子:
Java代码CREATEPROCEDUREtfer_funds(from_accountint,to_accountint,tfer_amountnumeric(10,2))BEGINSETautocommit=0;UPDATEaccount_balanceSETbalance=balance-tfer_amountWHEREaccount_id=from_account;UPDATEaccount_balanceSETbalance=balance+tfer_amountWHEREaccount_id=to_account;COMMIT;END;
使用START TRANSACITON打开事务的例子:
Java代码CREATEPROCEDUREtfer_funds(from_accountint,to_accountint,tfer_amountnumeric(10,2))BEGINSTARTTRANSACTION;UPDATEaccount_balanceSETbalance=balance-tfer_amountWHEREaccount_id=from_account;UPDATEaccount_balanceSETbalance=balance+tfer_amountWHEREaccount_id=to_account;COMMIT;END;
通常COMMIT或ROLLBACK语句执行时才完成一个事务,但是有些DDL语句等会隐式触发COMMIT,所以应该在事务中尽可能少用或注意一下:
Java代码ALTERFUNCTIONALTERPROCEDUREALTERTABLEBEGINCREATEDATABASECREATEFUNCTIONCREATEINDEXCREATEPROCEDURECREATETABLEDROPDATABASEDROPFUNCTIONDROPINDEXDROPPROCEDUREDROPTABLEUNLOCKTABLESLOADMASTERDATALOCKTABLESRENAMETABLETRUNCATETABLESETAUTOCOMMIT=1STARTTRANSACTION
3,使用Savepoint
使用savepoint回滚难免有些性能消耗,一般可以用IF改写
savepoint的良好使用的场景之一是“嵌套事务”,你可能希望程序执行一个小的事务,但是不希望回滚外面更大的事务:
Java代码CREATEPROCEDUREnested_tfer_funds(in_from_acctINTEGER,in_to_acctINTEGER,in_tfer_amountDECIMAL(8,2))BEGINDECLAREtxn_errorINTEGERDEFAULT0;DECLARECONTINUEHANDLERFORSQLEXCEPTIONBEGINSETtxn_error=1;ENDSAVEPINTsavepint_tfer;UPDATEaccount_balanceSETbalance=balance-in_tfer_amountWHEREaccount_id=in_from_acct;IFtxn_errorTHENROLLBACKTOsavepoint_tfer;SELECT'Transferaborted';ELSEUPDATEaccount_balanceSETbalance=balance+in_tfer_amountWHEREaccount_id=in_to_acct;IFtxn_errorTHENROLLBACKTOsavepoint_tfer;SELECT'Transferaborted';ENDIF:ENDIF;END;
4,事务和锁
事务的ACID属性只能通过限制数据库的同步更改来实现,从而通过对修改数据加锁来实现。
直到事务触发COMMIT或ROLLBACK语句时锁才释放。
缺点是后面的事务必须等前面的事务完成才能开始执行,吞吐量随着等待锁释放的时间增长而递减。
MySQL/InnoDB通过行级锁来最小化锁竞争。这样修改同一table里其他行的数据没有限制,而且读数据可以始终没有等待。
可以在SELECT语句里使用FOR UPDATE或LOCK IN SHARE MODE语句来加上行级锁
Java代码SELECTselect_statementoptions[FORUPDATE|LOCKINSHAREMODE]
FOR UPDATE会锁住该SELECT语句返回的行,其他SELECT和DML语句必须等待该SELECT语句所在的事务完成
LOCK IN SHARE MODE同FOR UPDATE,但是允许其他session的SELECT语句执行并允许获取SHARE MODE锁
死锁:
死锁发生于两个事务相互等待彼此释放锁的情景
当MySQL/InnoDB检查到死锁时,它会强制一个事务rollback并触发一条错误消息
对InnoDB而言,所选择的rollback的事务是完成工作最少的事务(所修改的行最少)
Java代码mysql>CALLtfer_funds(1,2,300);ERROR1213(40001):Deadlockfoundwhentryingtogetlock;tryrestartingtransaction
死锁在任何数据库系统里都可能发生,但是对MySQL/InnoDB这种行级锁数据库而言可能性相对较少。
可以通过使用一致的顺序来锁row或table以及让事务保持尽可能短来减少死锁的频率。
如果死锁不容易debug,你可以向你的程序中添加一些逻辑来处理死锁并重试事务,但这部分代码多了以后很难维护
所以,比较好的避免死锁的方式是在做任何修改之前按一定的顺序添加行级锁,这样就能避免死锁:
Java代码CREATEPROCEDUREtfer_funds3(from_accountINT,to_accountINT,tfer_amountNUMERIC(10,2))BEGINDECLARElocal_account_idINT;DECLARElock_cursorCURSORFORSELECTaccount_idFROMaccount_balanceWHEREaccount_idIN(from_account,to_account)ORDERBYaccount_idFORUPDATE;STARTTRANSACTION;OPENlock_cursor;FETCHlock_cursorINTOlocal_account_id;UPDATEaccount_balanceSETbalance=balance-tfer_amountWHEREaccount_id=from_account;UPDATEaccount_balanceSETbalance=balance+tfer_amountWHEREaccount_id=to_account;CLOSElock_cursor;COMMIT;END;
设置死锁ttl: innodb_lock_wait_timeout,默认为50秒
如果你在一个事务中混合使用InnoDB和非InnoDB表,则MySQL不能检测到死锁,此时会抛出“lock wait timeuot”1205错误
乐观所和悲观锁策略:
悲观锁:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续
乐观所:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新
一般在悲观锁的等待时间过长而不能接受时我们才会选择乐观锁
悲观锁的例子:
Java代码CREATEPROCEDUREtfer_funds(from_accountINT,to_accountINT,tfer_amountNUMERIC(10,2),OUTstatusINT,OUTmessageVARCHAR(30))BEGINDECLAREfrom_account_balanceNUMERIC(10,2);STARTTRANSACTION;SELECTbalanceINTOfrom_account_balanceFROMaccount_balanceWHEREaccount_id=from_accountFORUPDATE;IFfrom_account_balance>=tfer_amountTHENUPDATEaccount_balanceSETbalance=balance-tfer_amountWHEREaccount_id=from_account;UPDATEaccount_balanceSETbalance=balance+tfer_amountWHEREaccount_id=to_account;COMMIT;SETstatus=0;SETmessage='OK';ELSEROLLBACK;SETstatus=-1;SETmessage='Insufficientfunds';ENDIF;END;
乐观锁的例子:
Java代码CREATEPROCEDUREtfer_funds(from_accountINT,to_accountINT,tfer_amountNUMERIC(10,2),OUTstatusINT,OUTmessageVARCHAR(30))BEGINDECLAREfrom_account_balanceNUMERIC(8,2);DECLAREfrom_account_balance2NUMERIC(8,2);DECLAREfrom_account_timestamp1TIMESTAMP;DECLAREfrom_account_timestamp2TIMESTAMP;SELECTaccount_timestamp,balanceINTOfrom_account_timestamp1,from_account_balanceFROMaccount_balanceWHEREaccount_id=from_account;IF(from_account_balance>=tfer_amount)THEN--Hereweperformsomelongrunningvalidationthat--mighttakeafewminutes*/CALLlong_running_validation(from_account);STARTTRANSACTION;--Makesuretheaccountrowhasnotbeenupdatedsince--ourinitialcheckSELECTaccount_timestamp,balanceINTOfrom_account_timestamp2,from_account_balance2FROMaccount_balanceWHEREaccount_id=from_accountFORUPDATE;IF(from_account_timestamp1<>from_account_timestamp2ORfrom_account_balance<>from_account_balance2)THENROLLBACK;SETstatus=-1;SETmessage=CONCAT("Transactioncancelledduetoconcurrentupdate","ofaccount",from_account);ELSEUPDATEaccount_balanceSETbalance=balance-tfer_amountWHEREaccount_id=from_account;UPDATEaccount_balanceSETbalance=balance+tfer_amountWHEREaccount_id=to_account;COMMIT;SETstatus=0;SETmessage="OK";ENDIF;ELSEROLLBACK;SETstatus=-1;SETmessage="Insufficientfunds";ENDIF;END$$
5,事务设计指南
Java代码1,保持事务短小2,尽量避免事务中rollback3,尽量避免savepoint4,默认情况下,依赖于悲观锁5,为吞吐量要求苛刻的事务考虑乐观锁6,显示声明打开事务7,锁的行越少越好,锁的时间越短越好