企业应用程序基本上都是多用户同时使用的,而且很多情况下,还包括任务调度器或是外部系统触发的后台任务。所以,多个事务同时读写数据库是很常见的情况。开发人员面对的一个重大挑战就是:多个事务并发更新数据库带来的数据不一致的问题。你也许希望数据库自己就能够避免这种情况,但是保持数据一致通常是应用程序才能完成的任务。
出现数据不一致的情况主要有以下两种: 1.覆盖更新。一个事务把另一个事务的修改“盲目”地覆盖掉了,典型的场景是两个用户同时编辑一个数据,并且几乎同时提交,每个人都认为自己的更新成功了,但是有一人的提交其实已经被另一个人在不知情的情况下覆盖掉了。 2.脏读。一个事务读取了一个数据然后用这个数据去干别的事了,结果另一个事务却已经修改了这个数据,这可能造成第一个事务要去干的那个事情产生的数据出现不一致。
简单的说,就是将数据库的事务隔离级别设置为serializable,使所有事务都是串行执行的。但是就像它的名字所指出的那样,串行执行使得数据库的处理性能大幅度地下降,常常是你接受不了的。所以,一般来说,数据库的隔离级别都会设置为read committed(只能读取其他事务已提交的数据),然后由应用程序使用乐观锁/悲观锁来弥补数据不一致的问题。
虽然名字中带“锁”,但是乐观锁并不锁住任何东西,而是在提交事务时检查自己上次读取这条记录后,是否有其他事务修改了这条记录,如果没有则提交,如果被修改了则回滚。如果并发的可能性并不大,那么锁定策略带来的性能消耗是非常小的。
一般有三种方式实现乐观锁: 一是为数据表增加一个version字段,每次事务开始时,取出version,在提交事务时,检查version是否有变化,如果没有变化提交事务时将version + 1,SQL差不多是这样:
UPDATE T_IRS_RESOURCE
set version = version + 1
where resource_id = ?
and version = ?
二是为数据表增加一个时间戳字段,然后通过比较时间戳检查该数据是否被其他事务修改过。
三是检查对应的字段的值有没有变化。
一般来说,如果条件允许最好采用第一种方案。因为方案二受限于时间的精度,而方案三则开发起来很麻烦,而且有些时候浮点数的比较还不是很准确或者你需要处理null的情况。
接下来我会分别介绍使用iBATIS 2/hibernate 3实现乐观锁的简单示例。我们的假设场景非常简单,有两个用户要同时编辑一个Resource的name:
如果你使用iBATIS或其他jdbc风格的持久化框架,那么你需要自己实现乐观锁策略。方法也很简单,在更新数据之后检查一下版本号即可:
final int rowAffected = sqlMapClientTemplate.update(NAMESPACE + ".update", resource);
if (rowAffected == 0) {
throw new ObjectOptimisticLockingFailureException(Resource.class, resource.getId());
}
这样就能实现乐观锁并防止下图的问题了(注意第二个事务执行update时,第一个事务已经提交了,所以第二个事务能够读取到第一个事务修改的version)。
| the first transaction started |
| |
| select * from t_irs_resource where resource_id = ? |
| |
| update t_irs_resource set |
| version = version + 1, | the second transaction started
| name = ? |
| where resource_id = ? and version = ? | select * from t_irs_resource where resource_id = ?
| |
| commit txn | update t_irs_resource set
| | version = version + 1,
| | name = ?
| | where resource_id = ? and version = ?
| |
| | rolls back because version is dirty
不过iBATIS无法处理下面这种极端的情况:
| the first transaction started |
| | the second transaction started
| select * from t_irs_resource where resource_id = ? |
| | select * from t_irs_resource where resource_id = ?
| update t_irs_resource set |
| version = version + 1, | update t_irs_resource set
| name = ? | version = version + 1,
| where resource_id = ? and version = ? | name = ?
| | where resource_id = ? and version = ?
| commit txn |
| | rollback txn because version comparing fails
| |
在这种情况下,第二个事务的update由于不能读取第一个事务未提交的数据,它会认为version没有问题从而提交成功,破坏了数据一致性。这时最好使用hibernate这样的orm框架,通过试验(我在update语句之后,方法(即事务边界)退出前,让线程sleep 10秒,这时用另一个事务修改数据来检查结果)它可以解决这个问题,这是因为hibernate只有在事务提交时才把sql语句发送到数据库执行(而iBATIS则是按照代码顺序依次执行)。
使用Hibernate 3的话,实现乐观锁非常简单,只需要在映射文件中添加一行配置即可:
<hibernate-mapping default-access="field"
package="com.gmail.hippoom.irs.domain.model.resource">
<class name="Resource" table="T_IRS_RESOURCE" dynamic-update="true">
//omitted id config
<version name="version" column="version" />
//omitted properties config
</class>
</hibernate-mapping>
hibernate会自动帮我们完成乐观锁的检查工作。
总得来说,使用乐观锁并不一定安全(请考虑刚才说的极端的情况并且你在使用iBATIS),并且你必须保证所有对数据更新的代码都遵循乐观锁机制(否则相当于有人走后门),但是乐观锁来的额外开销最小,对于数据不是特别敏感的情况还是使用乐观锁为宜。
和乐观锁相比,悲观锁则是一把真正的锁了,它通过SQL语句“select for update”锁住数据,这时如果其他事务来更新时会等待:
| the first transaction started |
| |
| select * from |
| t_irs_resource where resource_id = ? for update |
| |
| update t_irs_resource set |
| version = version + 1, | the second transaction started
| name = ? |
| where resource_id = ? and version = ? | select * from |
| | t_irs_resource where resource_id = ? for update
| commit txn |
| | wait until lock released
| |
很简单,在SQLMAP中的查询语句中增加 for update即可。不过要注意的是,如果查询语句中有表关联,只是用for update会锁住所有表的相关记录,建议使用for update of t.id。如果你不想让你的事务等待其他事务的锁,可以在for update 后加上 nowait,这样当遇上冲突时数据库会抛出异常。
也很简单,在session.get()/load()时添加LockMode/LockOption参数即可,LockMode选择 LockMode.UPGRADE/LockMode.UPGRADE_NO_WAIT。
总的来说,悲观锁相对乐观锁更安全一些,但是开销也更大,甚至可能出现数据库死锁的情况,建议只在乐观锁无法工作时才使用。
前面我们讨论了如何使用乐观锁/悲观锁处理数据更新时并发问题,我们再来看看怎么使用它们处理重复新增的问题。假设这样一个场景,两个用户同时准备为同一个Resource新增SalesPlan,而且其DateRange都是2013-01-01到2013-01-01,但是需求要求SalesPlan的DateRanges不能重叠,但这时你又无法使用数据库唯一键来实现这个需求(因为日期段可能存在 2013-01-01到2013-01-10和 2013-01-02到2013-01-03 的重叠情况)。要解决这种问题的关键在于锁住Resource,虽然我们并不需要更新Resource,但是如果锁住了它,另一个新增SalesPlan的事务中由于也要获取Resource,最后就会由于Resource冲突而失败。
这里要注意,如果使用hibernate,且使用乐观锁,那么你需要手工的更新一下Resource,否则hibernate会认为Resource没有变化而不触发Resource的更新导致整个策略失效。我使用了一个小花招来应对这个情况:
public class Resource {
//omitted fields
private int version = 1;
private boolean dirty = false;
public void alwaysMakeDirty() {
this.dirty = !dirty;
}
}
public class HibernateResourceRepositoryImpl implements ResourceRepository {
//omitted code
@Override
public void store(Resource resource) {
resource.alwaysMakeDirty();
if (resource.isUnsaved()) {
hibernateTemplate.save(resource);
} else {
hibernateTemplate.update(resource);
}
resource.saved();
}
}
这时由于dirty总是会变化(本来是true变为false,本来是false变为true),hibernate会认为Resource已被修改从而触发update语句验证version。
如果你担心的是比如有一张主表,还有若干字表通过外键关联主表,是否需要锁住所有的行/检查所有的行的version。确实,这样做会给开发带来很大麻烦,需要非常细心的检查以免遗漏了某把锁,不过一般对于这种情况,推荐采取Aggreate策略,即以主表作为锁定对象,不锁定子表,但是所有对子表的访问和操作都必须先获得主表的锁。这样相对来说,开发工作量就小多了,开发人员实现锁策略时也不用绞尽脑汁判断某个表是否要锁定。
A:这是一个好问题。不过强烈推荐将自定义的异常设计为非受检的,原因如下:
1.如果期望调用者能够适当地恢复/处理错误,对于这种情况就应该使用受检异常,但在实际情况中,这个原则被大量的误用,在绝大多数的情况下,系统报告了错误,我们都很难处理,最简单(有时甚至是唯一的办法)就是直接把错误报告给用户。
2.受检异常要求客户端一定要继续抛出或是处理异常,由于第一个原因,常常导致客户端选择继续抛出异常,如果这个异常是系统较低层的组件抛出的,你经常会看到如下场景:
Listing-1 nested throws
public class TopService {
private MiddleService mService;
public void doBusiness() throws ACustomeCheckedException {
mService.doBusiness();
}
}
public class MiddleService {
private Dao dao;
public void doBusiness() throws ACustomeCheckedException {
dao.save();
}
}
public class Dao {
public void doBusiness() throws ACustomeCheckedException {
//omitted persistence logic
}
}
由于底层的Dao声明抛出一个受检异常,导致所有直接/间接调用它的组件也不得不声明抛出该异常,情况更糟的是,如果此时Dao由于实现变化又需要抛出另一个受检异常,这时你不得不修改所有直接/间接调用它的组件,让它们也抛出这个新的异常。这造成添加了一个底层异常,要改动大量的高层组件,使得添加新的异常非常麻烦,所以有时候干脆选择将一些系统底层(可能是框架)抛出的受检异常包裹成非受检异常,比如Spring framework就将受检异常SQLException包裹在自定义的非受检异常DataAccessException中。
基于以上两个原因,我推荐绝大多数(如果不是所有)情况下,让你的自定义异常继承 RuntimeException ,使其成为非受检异常。
A:简单地说,一定要包括描述和上下文。你是否遇到这样的情况:有个任务要分析日志,检查系统运行情况,结果你在日志看到一句:
“Error!Cannot cancel order!”
然后就没了,这个时候,真心郁闷啊,既不知道是哪张订单,也不知道为什么不能取消订单,只能查看代码:
Listing-2 a mysterious error
public class Order {
public void cancel() {
if (order.isLocked()) {
throw CannotCancelOrderException.hasBeenLocked(orderId);
}
if (order.isCanceled()) {
throw new CannotCancelOrderException();
}
//omitted code
}
}
如果重构一下,修改为这样就好多了:
Listing-3 exception with context and description
public class Order {
public void cancel() {
if (order.isLocked()) {
throw CannotCancelOrderException.hasBeenLocked(orderId);
}
if (order.isCanceled()) {
throw CannotCancelOrderException.hasBeenCanceled(orderId);
}
//omitted code
}
}
public class CannotCancelOrderException extends RuntimeException {
public static CannotCancelOrderException hasBeenLocked(String orderId) {
return new CannotCancelOrderException("order["+ orderId + "] has been locked");
}
public static CannotCancelOrderException hasBeenCanceled(String orderId) {
return new CannotCancelOrderException("order["+ orderId + "] has already been canceled");
}
public CannotCancelOrderException(String message) {
super(message);
}
}
如果你的异常是由其他异常导致的,可别把cause落下了:
Listing-4 wrapped exception
public class Order {
public void cancel() {
//omitted code
} catch (SupplierAccessException e) {
throw CannotCancelOrderException.wrapped(orderId, e);
}
}
}
public class CannotCancelOrderException extends RuntimeException {
//omitted factory methods and constructors
public static CannotCancelOrderException wrapped(String orderId, Throwable t) {
return new CannotCancelOrderException("Cannot cancel order[" + orderId + "] due to " + t.getMessage(), t);
}
public CannotCancelOrderException(String message, Throwable t) {
super(message, t);
}
}
A: 比如在一个MVC Controller中,调用了下单服务:
Listing-5
String orderId = placeOrderService.placeOrder(productCode, quantity, price);
由于使用了Spring framework来提供声明式事务,所以如果下单时如果发生错误,我们会让placeOrder()会抛出异常从而触发回滚。为了不让用户看到500报错及满屏的错误堆栈,一般会使用try/catch块捕获错误:
Listing-6
try {
orderId = placeOrderService.placeOrder(productCode, quantity, price);
} catch (Exception e) {
model.addAttribute("error", e.getMessage());
//omitted logging code if placeOrderService doesn't log on it own
}
由于大多数情况下,我们只是要报告错误,一般推荐在最靠近的用户的“层”中捕获异常并根据UI接口填充错误报告(对于View可能是将错误报告放入HttpServletRequest对象待渲染,对于远程访问接口可能是翻译并返回错误码)。
A: 如果要返回错误码而不是直接抛出异常,那么总会有一个组件会捕获所有的异常并进行翻译。最简单的解决方案是硬编码:
Listing-7 hard coded status code translation
try {
orderId = placeOrderService.placeOrder(productCode, quantity, price);
} catch (InsufficientInventoryException e) {
statusCode = INSUFFICIENT_INVENTORY;
} catch (ExpriedPriceException e) {
statusCode = EXPIRED_PRICE;
} catch (NoSuchProductException e) {
statusCode = NO_SUCH_PRODUCT;
} catch (Exception e) {
statusCode = UNKNOWN;
}
但是如果异常很多,这里的翻译篇幅就会比较大,而且如果要新增一个异常,就要回来修改catch,维护起来很麻烦。由于这些异常都是自定义的,我们可以将其设计成一个体系(Hierachy):
Listing-8 exception hierachy
public class PlaceOrderServiceFacadeImpl implements PlaceOrderServiceFacade {
public PlaceOrderRs handle(PlaceOrderRq rq) {
//omitted code
try {
orderId = placeOrderService.placeOrder(productCode, quantity, price);
statusCode = SUCCESS;
} catch (UncheckedApplicationException e) {
statusCode = e.getStatusCode();
} catch (Exception e) {
statusCode = UNKNOWN;
}
return new PlaceOrderRs(statusCode, order);
}
}
public abstract class UncheckedApplicationException {
//omitted factory methods and constructors
public abstract String getStatusCode();
}
要新增异常的话,只需要让其继承UncheckedApplicationException并实现getStatusCode()即可。同样的,如果除了要告知statusCode,如果还需要返回本地化的提示信息,还可以加上getI18nCode()、getI18nArgs()这样的方法:
Listing-9 i18 supported exception hierachy
public class PlaceOrderServiceFacadeImpl implements PlaceOrderServiceFacade {
public PlaceOrderRs handle(PlaceOrderRq rq) {
//omitted code
try {
orderId = placeOrderService.placeOrder(productCode, quantity, price);
statusCode = SUCCESS;
message = messageSource.getSuccess(locale));
} catch (UncheckedApplicationException e) {
statusCode = e.getStatusCode();
message = messageSource.getMessage(e, locale));
} catch (Exception e) {
statusCode = UNKNOWN;
message = messageSource.getUnknownError(e, locale));
}
return new PlaceOrderRs(statusCode, order, message);
}
}
public abstract class UncheckedApplicationException {
//omitted factory methods and constructors
public abstract String getStatusCode();
public abstract String getI18nCode();
public abstract String[] getI18nArgs();
}
public class DelegateToSpringMessageSourceFacadeImpl implments MessageSourceFacade {
public String getMessage(UncheckedApplicationException e, Locale locale) {
return delegate.getMessage(e.getI18nCode(), e.getI18nArgs(), locale);//delegate to Spring MessageSource
}
}
另外,因为往往一个WebService的实现中有多个方法,如果使用之前的方案,那么每个方法都要编写一次try/catch块,但是内容是一样的,对于这类情况,我推荐使用AOP的方式来捕获异常:
Listing-10 a web service impl example
public class PlaceOrderServiceFacadeImpl implements PlaceOrderServiceFacade {
public QuoteRs handle(QuoteRq rq) {
//omitted code
try {
//omitted quoting code
statusCode = SUCCESS;
message = messageSource.getSuccess(locale));
} catch (UncheckedApplicationException e) {
statusCode = e.getStatusCode();
message = messageSource.getMessage(e, locale));
} catch (Exception e) {
statusCode = UNKNOWN;
message = messageSource.getUnknownError(e, locale));
}
return new QuoteRs(statusCode, quotes, message);
}
public PlaceOrderRs handle(PlaceOrderRq rq) {
//omitted code
try {
orderId = placeOrderService.placeOrder(productCode, quantity, price);
statusCode = SUCCESS;
message = messageSource.getSuccess(locale));
} catch (UncheckedApplicationException e) {
statusCode = e.getStatusCode();
message = messageSource.getMessage(e, locale));
} catch (Exception e) {
statusCode = UNKNOWN;
message = messageSource.getUnknownError(e, locale));
}
return new PlaceOrderRs(statusCode, order, message);
}
}
Listing-10 an around advice catching exception
public class LogAndReturnHandler implements MethodInterceptor {
@Setter
private LoggingSupport loggingSupport;
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
//omitted logging code
try {
//omitted logging code
Object returning = invocation.proceed();
//omitted logging code
return returning;
} catch (UncheckedApplicationException e) {
//omitted logging code
return populateRs(invocation, e.getStatusCode(), e.getMessage());
} catch (Throwable t) {
//omitted logging code
return populateRs(invocation, StatusCode.UNKNOWN_ERROR,
t.getMessage());
}
}
private Class<?> returnTypeOf(MethodInvocation invocation) {
return invocation.getMethod().getReturnType();
}
private GenericRs aGenericRsWith(String statusCode, String message) {
return new GenericRs(statusCode, message);
}
private Object populateRs(MethodInvocation invocation, String statusCode,
String message) {
final GenericRs aGenericRs = aGenericRsWith(statusCode, message);
Object object = mapper(invocation, aGenericRs);
return object;
}
private Object mapper(MethodInvocation invocation,
final GenericRs aGenericRs) {
ModelMapper mapper = new ModelMapper();
Object object = mapper.map(aGenericRs, returnTypeOf(invocation));
return object;
}
}
public class PlaceOrderServiceFacadeImpl implements PlaceOrderServiceFacade {
public QuoteRs handle(QuoteRq rq) {
//omitted quoting code
return new QuoteRs(SUCCESS, quotes, successMessageFor(locale));
}
public PlaceOrderRs handle(PlaceOrderRq rq) {
//omitted code
orderId = placeOrderService.placeOrder(productCode, quantity, price);
return new PlaceOrderRs(SUCCESS, order, successMessageFor(locale));
}
private String successMessageFor(Locale locale) {
return messageSource.getSuccess(locale));
}
}
spring xml
<bean id="irs.MemberBookingServiceFacadeImpl" class="org.springframework.aop.framework.ProxyFactoryBean">
<property name="target" ref="irs.MemberBookingServiceFacadeImplTarget" />
<property name="interceptorNames">
<list>
<value>irs.LogAndReturnHandler</value>
</list>
</property>
</bean>
这里的要点在于虽然每个方法都会catch异常,但是每个方法返回类型是不一致的(比如QuoteRs/PlaceOrderRs),需要找到一种方法能根据返回类型自动实例化并填充statusCode和message的方法(一般发生错误时,没有业务数据需要返回),我这里使用的是一个开源的library:ModelMapper,只要所有的Rs(比如QuoteRs/PlaceOrderRs)都和GenericRs一样有statusCode、message两个属性,ModelMapper会自动完成映射Rs的任务,非常方便(可以让所有的Rs都继承GenericRs降低属性名称不一致的风险)。
A: 这主要看应用程序准备怎么使用这个自定义的异常体系了。理论上来讲,即使是为了翻译statusCode,整个异常体系也只需要一个自定义异常就可以满足使用:
Listing-10 GenericApplicationException
public class GenericApplicationException extends RuntimeException {
@Getter
private String statusCode;
@Getter
private String i18nCode;
@Getter
private String[] i18nArgs;
public GenericApplicationException(String statusCode, String message, String i18nCode, String[] i18nArgs, Throwable cause) {
super(message, cause);
this.statusCode = statusCode;
this.i18nCode = i18nCode;
this.i18nArgs = i18nArgs;
}
}
这个异常就非常通用,但是不易用,因为抛出它的组件需要自己负责输入statusCode, message, i18nCode, i18nArgs, cause。这样来看的话,你可以根据错误的来源设计一些特定的异常以简化异常的抛出代码,比如:
Listing-10 CannotCancelOrderException
public class CannotCancelOrderException extends UncheckedApplicationException {
private String orderId;
private String i18nCode;
public static CannotCancelOrderException hasBeenLocked(String orderId) {
return new CannotCancelOrderException("order["+ orderId + "] has been locked", orderId, "i18n.order.orderHasBeenLocked");
}
public static CannotCancelOrderException hasBeenCanceled(String orderId) {
return new CannotCancelOrderException("order["+ orderId + "] has already been canceled", orderId, "i18n.order.orderHasBeenCanceled");;
}
public CannotCancelOrderException(String message, String orderId, String i18nCode) {
super(message);
this.orderId = orderId;
this.i18nCode = i18nCode;
}
@Override
public String getStatusCode() {
return CANNOT_CANCEL_ORDER;
}
@Override
public String getI18nCode() {
return i18nCode;
}
@Override
public String[] getI18nArgs() {
return new String[] {orderId};
}
}
i18n-zh-CN.properties
i18n.order.orderHasBeenCanceled = 订单{0}已被取消
i18n.order.orderHasBeenLocked = 订单{0}已被锁定
CannotCancelOrderException将statusCode、i18n、message的拼装隐藏了起来,这样抛出CannotCancelOrderException的组件就轻松多了,只需要输入orderId即可。如果编写单元测试,这种情况就更明显了,如果设计得当,抛出异常的组件在测试中也不用关心异常的信息细节,异常的信息细节可以在异常的单元测试中验证。
Listing-11 unit test for exception
public class CannotChangeOrderExceptionUnitTests {
private CannotChangeOrderException target;
@Test
public void tellsOrderIsCanceled() throws Exception {
final String orderId = "1";
target = CannotChangeOrderException.orderIsCanceled(orderId);
Assert.assertEquals("订单[1]已被取消", target.getMessage());
Assert.assertEquals(StatusCode.CANNOT_CHANGE_ORDER,
target.getStatusCode());
Assert.assertEquals(
"com.springtour.irs.application.exception.CannotChangeOrderException.orderIsCanceled.message",
target.getI18nCode());
}
@Test
public void tellsOrderIsHolding() throws Exception {
final String orderId = "1";
target = CannotChangeOrderException.orderIsHolding(orderId);
Assert.assertEquals("订单[1]预占中", target.getMessage());
Assert.assertEquals(StatusCode.CANNOT_CHANGE_ORDER,
target.getStatusCode());
Assert.assertEquals(
"com.springtour.irs.application.exception.CannotChangeOrderException.orderIsHolding.message",
target.getI18nCode());
}
}