从唯一性说起
写了十几年代码,直到现在,我见过非常多的处理唯一性约束的方法都是放在代码里,而非数据库里。
直到现在我也一直很困惑,这些人为什么不使用数据库的唯一索引呢?不过我并不想知道这个答案。
他们的做法很简单,假如要保证name是唯一的,先使用Java代码执行一个查询语句:
select * from example where name = ?
然后根据返回值来判断,如果是null则表明没有这个name,接着执行插入语句即可:
insert into example(name) values(?)
如果不是null则表明这个name已经存在,那就返回name已存在的提示。
如果系统并发很小或者不是人为故意测试,这种方式完全没有问题。
然而事实证明的是,还是偶尔会遇到问题,会出现name一样的记录。
类似这样的情况还有抽奖问题,那就是判断奖品是否还有剩余。
他们通常的做法也是先查询奖品剩余数量,如下这样:
select remain_count from example where id = ?
然后判断返回值,如果大于0则表明奖品还有,则执行更新语句:
update example set reamin_count = remain_count - 1 where id = ?
如果不大于0则表明奖品没有了,就返回奖品已经抽完的提示。
这种方案在奖品数量趋于0这个临界值时一定会出问题,因为大部分抽奖都是有一定并发性的。
到最后会发现剩余奖品数量不是0而是负的,这些问题我都见过,好歹客户不难缠,只需把多出的奖品钱掏了就行。
我实在想不通写这些代码的人是基于什么考虑的,这样的写法不仅代码写得多,而且也无法百分之百保证。
如果是我年轻的时候,一定会在心里“骂”这样的代码和写代码的人。
不过现在“老”了,很多事情都放得下了,权当“闭一只眼,再闭一只眼”了,况且我又不是项目经理。只要大方向不跑偏就行了。
也许这样的人,人家就是把写代码当作一份糊口的工作而已,人家不爱好这个,不愿意想太多,我们也无可非议。
当然,我不使用这种方法,我一般会在数据库里加上唯一索引,然后尽情的insert吧。
如果没有唯一键冲突,那就一定会插入成功,如果有唯一键冲突,那就一定会抛异常,Spring把这个异常进行了转化。
它就是DuplicateKeyException,我们只需try一下即可:
try {
xxxMapper.insertXXX(..);
return 1;
} catch (DuplicateKeyException ex) {
log.warn(..);
return -1;
}
我们不去讨论那种方法好,至少这种做法代码写的少,而且使用数据库的唯一索引,绝对不会出现重复记录。
我以为的我以为
如果有较大量数据需要插入的话,我们都会使用批量插入,如果使用Mybatis的话就是<foreach>标签了。
但是有一个问题,如果插入的数据有重复的话,而且数据库要求不能重复且还建了唯一索引,这时批量插入就没法用了。
因为只要有一个唯一键冲突,这批数据都得完蛋。这其实没有什么非常好的方法,不过可以先拿待插入数据进行检测,把重复的直接排除掉。
但是需要写更多的代码,有些繁琐。实在不行,只要时间上要求不高,还是采用单条插入吧。
我认为,如果有大量数据需要插入而且还要不重复,关键是数据里真有重复的,还是先对数据进行预处理,否则批量插入用不了,单条插入又非常耗时。
我就遇到了这样的遗留问题,有重复的数据,所以不能使用批量插入,好歹数据量不大,那就单条单条的来吧。
按照我们的理解,单条数据唯一键冲突只影响这一条,肯定会抛异常,我们只要try/catch住,不会影响下一条的插入。当然,这是我以为的。
代码当然是这样写的:
int count = 0;
for (XXX xxx : xxxList) {
try {
xxxMapper.insertXXX(xxx);
count++;
} catch (DuplicateKeyException ex) {
log.warn(..);
}
}
return count;
先不要说for里面使用try/catch是不是合理,世界上哪有那么多的合理啊,快速解决问题才是王道,不合理的事情留到以后再说。
如果这样真的可以的话,那也算是一种解决方法。可惜的是,一旦遇到唯一键冲突,异常虽然catch住了,但是事务照样中止了,看来,“我以为的”还真成了我以为的。
我进行了多次其它尝试,如catch更多的其它类型的异常,发现只能延迟事务的中止,但最后还是中止。我又在事务注解上设置不回滚某些类型的异常,发现还是不行。
多次尝试之后,我放弃了,因为这是别人的或系统的遗留问题,没有什么好的解决办法,或者也改为别人的写法,先查询再插入,但是需要写更多的代码,也没有太多时间了。
于是就决定不使用事务了,把事务注解去掉。问题得以解决了。后来还发现,这个方法被别的带事务的方法调用了,默认又在事务里了,索性干脆直接使用注解标记为不支持事务。
掐断了事务的传播之后,这下真与事务绝缘了,世界清净了。
所以,在从零开发新系统的时候,一定要多思考,不管是项目经理还是开发人员,一定要知道现在的某种做法会在日后带来什么问题,如果什么都不想,日后必定会有很多奇葩的问题,简直莫名其妙。
最终,我们不得不承认,没有最烂的代码,只有更烂的代码。
重新认知Spring事务
说句心里话,这个事情真的让我很意外,虽然我很少有“意外”,本以为可以的,结果却是不行。于是我就仔细的思考。
Spring的事务给人的印象就是抛出了某些异常可以回滚,抛出了某些异常可以不回滚,而且是可以配置的,默认只回滚运行时异常。
这仿佛是在说明Spring可以catch住指定的异常,然后提交事务,或catch住某些异常,然后回滚事务,再把异常抛出给我们。
照这样理解,那我们自己catch住异常岂不更好,不用劳Spring大驾,事实是不完全行的。由于Spring的事务行为是运行时通过生成子类注入的,所以没有现成的源码可看。
由于这件事,我又想起了我年轻时候的困惑,由于后来就不再想这个困惑了,所以一直没有得到答案。
Spring把事务加在Service层的方法上,但很多时候,这些方法仅仅就是执行一个sql语句而已,无论是insert、update还是delete。
按照通常的理解,只有在涉及多个sql操作的时候才需要事务,这样它们要么全部成功,要么有一个报错就全部回滚,这也正是事务的原子性。
但是只有一个sql操作时,理论上不需要事务,因为它的成功与否并不会对别的sql产生影响,因为只有一个sql操作,默认就是原子的。而且一个sql操作,要么成功要么失败,不会出现一半成功一半失败的情况,这是数据库保证的。
这个逻辑推理本身是没有错的,只是有些狭隘,因为我们把这个事务仅仅看作是数据库的事务,仅仅把它限制在数据库里了。这就是上面的一个疑惑的缘由,为什么只有一个sql操作也开启事务。
Spring把事务加在Service层,其实是扩大了事务的范围,把事务从数据库里拿了出来,放到了Service层的Java代码里了。让我们的业务代码也融入到了事务里。
我们可以先执行若干sql操作,没有抛异常,然后再执行业务代码,如果业务代码抛了异常,Spring可以回滚事务,这样先前的sql操作就撤销了,宏观来看sql操作和业务代码就在一个事务里。
只不过很多时候我们没有业务代码,所以就只剩下一个sql操作了,因此也开着事务,这就解释了前面的疑惑,为什么只有一个sql操作也开着事务。
于是我有一个大胆的猜测,Spring事务里说的“对哪些异常回滚和不回滚”这里的异常应该指的是业务代码里抛出的异常,而不是对数据库执行sql操作时抛出的异常。
因为执行业务代码时抛出的某些异常可能并不影响对数据库的操作,当然这是站在业务的角度来说的,所有Spring照样可以提交事务,让对数据库的sql操作生效。
但是如果在对数据库执行sql操作时抛出了异常,则一定会选择回滚事务,毕竟这个事务是从数据库里引出来然后扩大到整个业务层,而不是倒过来。
我感觉Spring可以通过异常类型来判断是业务代码抛出的还是数据库操作抛出的,如果是业务代码抛出的,我们可以自己catch住或配置为不回滚,则最终照样提交事务。
如果是对数据库执行操作时抛出的,则总是会回滚事务,即使我们自己catch住或配置为不回滚,也照样没有用,最后都会回滚,毕竟数据库操作失败,不应该再有任何幻想。
这样就可以解释本文开头说的情况,虽然catch住了唯一键冲突异常或把该异常配置为不回滚,但是事务照样中止。
注意,这些只是我的猜测,欢迎留言分享自己的看法或想法或猜测。