本文原创,禁止转载。
对于很多没有接触过高并发项目的程序员来说,锁这个概念熟悉又陌生。为什么这么说,因为我们常听常看这个概念,却没怎么遇到过这个问题。事实上,对于一个传统的写管理页面的程序员来说,这个问题太少见了,但也并非一定遇不到。下面我就介绍一个做管理页面时遇到的并发问题。
前端框架layui
不少人都写过上传,但这里介绍的是多图片上传,在layui(不局限)中允许你选多张图片来上传,可以在layui官方实例中尝试,点击这里体验。
当你选择多张图片上传后,会发现前端框架发起了多个上传请求,一张图片对应一个请求,而且这些请求发起的时间间隔非常非常短,短到足以让多个请求产生的多个事务争抢,短到会让你发现,原来你之前写的很多代码并不是没问题,只是没有达到触发它们产生问题的条件,也解释了为什么一个项目运行的久了,总有些脏数据,有些脏数据好没道理,让你一度以为肯定是因为莫名其妙的外部因素,绝对不是你的代码问题,但事实上,你的代码真的有可能有很多问题。
这里啰嗦一下,举个简单的栗子来阐述并发问题,就好比你在穿马路的时候,你想要到马路对面去,这时候,你会往前往后看看是否有车辆,如果没有车辆,我们就过马路,并且我们一直都是这么过的,似乎也从没有什么问题,但是这仅仅是在马路上车辆不多,并且车速也不快的情况,试想,如果在你确认马路前后都没有车辆后,后方突然有一辆汽车从拐角处出来,并以极快速度驶来,悲剧可能就发生了。代码也是这个道理。
我这里解释一下我所说的业务场景,以及代码产生问题的原因。(如果不能理解这个业务场景,那下面的处理方式就很难理解了)
场景:当我在前端页面上选择多张图片的时候,后端需要做三个操作,涉及到两张表。
一张上传图片的条件表(product_image),一张图片表(product_image_list),product_image_list里面的url记录了上传的图片地址,product_image的主键id与product_image_list的img_id是外键关系,以此形成一对多关系(对于理解非常重要)。这里业务需求简化了说就是在product_image表中,type和product_id这条记录是唯一的,即
上面这条记录在product_image中只可能存在一条,而另一张表中的记录则如下:
上面说了代码要做三个操作:
1、判定product_image中是否存在上面那条记录,如果有,则取这条记录的id,如果没有则新增这条记录,总之我要这条记录的id。
2.上传图片
3.如果图片上传成功,则把上传成功的地址记录到product_image_list中,如上图。
相信这个业务不难理解,上图有三条记录,说明product_id=180019和type=0的上传条件已经上传了三张图片。
结合注释不难理解。(代码中倒数第三行插入数据,mybatis会将主键返回到对象中)
如果没有并发,那么以上代码没有任何问题。
但如果有并发,就会出现一种奇异的错误,现在我同时选两张图片上传,layui同时发起两个请求,两个请求同时到后端,经由前端控制器转发到相应的controller,然后到service时,同时执行上面的代码,然后同时查询这条记录是否存在,然后同时发现,这条记录不存在,然后执行插入操作,结果执行完后,你发现product_image中出现了两条数据,于是就错了。
如果你没理解上面的讲述,那么建议你再看一遍,因为下面我将就上面这个问题,多维度讨论。
1、不是说有事务吗?怎么没生效?
相信很多没接触过这类问题的人都会这么想,毕竟平时写代码只需要考虑这条数据是否写入成功,对于事务只是粗浅的理解到commit和rollback的程度,事务的原子性、持久性、一致性都好理解,但事实上,在处理高并发问题时,事务的隔离性是需要被重点对待的。
这里所用的是MySQL的InnoDB,隔离级别(不做重点讲述)有read-uncommitted、read-committed、repeatable-read、serializable,默认隔离级别为repeatable-read,有些文档上写了这种隔离级别没法避免幻读,但事实上InnoDB是的事务是通过MVCC来实现的,是可以解决幻读的问题的。
那么是事务没生效吗?我们可以试着故意写错代码看事务是否会回滚,会发现它当然是生效的。
上面这段代码可以注解到类上或者方法上,表明事务生效。
isolation = Isolation.DEFAULT
这段代码表明,spring框架会沿用数据库的隔离程度,并不会冲突。(不写默认)
那么就是说,数据库的隔离程度生效了,却没能阻止上面的错误,是这样吗?
答案是肯定的。
MVCC实现的可重复读虽然可以避免很多问题,但却没法避免上面的业务问题(具体可以仔细理解可重复读)。
2、那既然事务生效了,而事务又不能解决这个问题,那该怎么办?加锁么?
相信很多人的第一直觉就是这个,因为我们在JAVA SE中学到的唯一对付并发问题的办法就是这个了。那我们是不是只需要在读和写上加个锁就可以了呢?毕竟我们只需要这段代码是原子操作就可以了。
但很可惜的,如果不涉及到数据操作的话,这段代码是可行的。可是数据库中的数据除非已经提交了,否则即便两个请求在锁定的代码块串行了,依旧没法有办法被其它事务读取,那难道要我们把事务隔离程度换成未提交读吗?当然是不行的,事实证明,如果你换成未提交读, 会报出你更看不懂的错误。
这时,你灵机一动,既然锁住的代码块因为没提交而逻辑错误,那我们为什么不锁到一个事务提交完后再让下一个事务进来呢?
可是代码该怎么实现呢?
很简单,我们只需要再controller中加这个锁就可以了,事实证明,这个方法是可行的。
但如果对这个问题仅止步于此,我也不必写这篇文章了。
因为这个方法虽然解决了问题,但这种锁的开销太大了,更何况这里还需一个上传文件的操作,如果上传的时间太长,难道其它请求就这么一直阻塞下去?当然是不行的。
3、所以如果要减小开销,那么这个锁就必定要加在service中了,可是该怎么加呢?
我们刚才在service中加锁是因为数据还没提交,所以下个事务读取的时候,依旧读到的是没有数据,那我们是不是可以用不用提交就能够读取到的数据呢?
这是当然的。
这就好比,我们需要一个信号来判断下一步来怎么操作。在这个业务中,我们需要判断product_image中是否有数据,而这个数据存在与否,其实就是对应一个1或者0,并且是一个不受当前MySQL控制的1或者0,这个信号怎么表达呢?有太多太多方法可以表达,你甚至可以在计算机上用建一个文件是否成功来表达,但这里用这种方法显然不适合(总不见得一条记录一个文件,那岂不是要成千上万个文件),我们这里有更合适的东西,那就是Redis数据库,利用Redis的独立性,我们完全可以用一个KEY来表达这个信息。事实上,这个信息,就是锁,一个更广义的,不局限于JAVA API的抽象的锁的概念。
上面的代码还是很好理解,设定key的规则为product_image:type:productId,那么我们只需要判断这个key在redis中是否存在,如果不存在,则我们在MySQL和Redis中双写这条数据,并将id返回,如果存在,则直接将这个id返回,这里的判断条件成了Redis中的一条数据,不再受MySQL事务的制约,因此看似很完美的解决了这个问题。
当你写完上面的代码,运行结束,发现确实可行的时候,也许大喜过望,这个问题终于得到了解决,然而,我们的眼光应该不局限于此了。因为如果代码不做分布式的话,那确实没什么问题,但如果以上代码被布到不同的服务器上,甚至还做了负载均衡,那么请问sync这样的JAVA API还有用么?
当然没用了,sync只能锁定一个线程,但现在都不在一个服务器上,遑论解决并发?!
还有一个问题,那就是如果Redis做了集群的话,那以上代码会有一致性问题。怎么理解呢?Redis集群中,不算数据分片,只是主从模式的读写是分离的,那么也就是说,写是由主Redis来完成,读是由众多从Redis来完成,但以上代码的设计,如果主和从之间的数据存在延时该怎么办呢?对于这种强一致性问题的业务岂不是完蛋?事实上数据一致性问题也是分布式中常见的问题。
4、这也不行,那也不行,难道就没有完美的解决方案吗?
办法当然有,既然主从存在数据一致性的问题,那么我们是不是可以用写命令来作为信号判断呢?因为写命令就只会由主Redis来处理,这样不就能够解决了?Redis中就有这么一个命令,SETNX这个命令是Redis中分布式锁的基础。
我这个项目中还没集成队列,如果集成队列很多补偿措施就能够写的很好,这里的代码也并非完全没有问题。代码中最后几行是让线程沉睡100ms,事实上如果有队列,重新塞入队列即可。 但我相信,利用setnx来解决这类问题的理念已经传达到了,这就是分布式锁。不再局限于sync,也不受分布式,集群等影响的一个广义的锁的概念。