背景描述
简单说一下背景,我们用mysql维护了一份对象存储的对象列表,当有文件上传的时候,需要判断数据库中有没有对应的记录,有则更新,没有则新增。由于使用的是逻辑删除,不能简单的对bucket和object建立唯一索引。
表结构示例:
| id | int(11) |
|---|---|
| bucket | varchar(256) |
| object | varchar(256) |
| is_delete | bit(1) |
入库代码示例:
if(bucket and objcet not exist){
insert
}else{
update
}
问题分析
由于网络延迟或用户误操作,导致按钮被多次点击,短时间内服务器收到多条重复的数据,由于时间间隔非常小,前一次请求的 insert 还没有提交,第二次请求已经进入并通过了 if 验证,导致数据库里存储了两条相同的数据。
这里就要说到 MySQL 的事务。
事务并发的读现象
脏读: 当一个事务允许读取另外一个事务未提交的修改时,就可能发生脏读。
不可重复读: 在一次事务中,当一行数据获取两遍得到不同的结果表示发生了不可重复读。
幻读: 在事务执行过程中,当两个完全相同的查询语句执行得到不同的结果集,这种现象称为幻读。
事务隔离
MySQL的事务隔离一共有四个级别,分别是读未提交、读已提交、可重复读、可串行化。隔离的作用是让事务之间互相隔离、互不影响,保证事务的一致性。
读未提交:最低的隔离级别,事务可以读到其他事务尚未提交的修改。可能会发生脏读、不可重复读和幻读问题。
读已提交:事务只能读到其他事务提交后的修改。可能会发生不可重复读和幻读问题。
可重复度:是MySQL默认的隔离级别,在事务执行期间,会锁定该事务引用的行,如果执行相同的SELECT,产生的结果总是相同的。但是可能会发生幻读问题。
可串行化:可串行化是最高的隔离级别,它通过强制事务串行执行,避免了前面说的幻读的问题。
然而无论哪种隔离级别,如果两个事务同时select,可能都查不到该数据,结果是两个事务都插入成功,因此单纯的调整隔离级别,无法解决此问题。
解决思路
改进表结构
因为采用的是逻辑删除,因此objcet、bucket值相同的记录和能有多条,所以不能通过给(objcet、bucket)加唯一索引来解决。这里考虑引入一个字段del_timestamp ,默认为0,删除时设置为当前时间戳,对(object、bucket、del_timestamp)加唯一索引。
这样insert重复数据时,在程序中就可以捕获到异常。
锁表
使用MySQL提供的锁表语句LOCK TABLES 和 UNLOCK TABLES 。由于锁住了整个表的读写,导致效率太低。
synchronized同步锁
使用 synchronized 同步相关代码块,但是这样会导致正常的请求也被阻塞,严重影响效率。因此考虑只对重复数据进行加锁。
String lock = bucket + objcet ;
synchronized(lock.intern()){
//...
}
分布式锁
上面的 synchronized同步锁 方案,原理是利用JVM的字符串常量池,但是在跨进程或跨服务器的情况下就会失效。
所以考虑到把上面的 lock 字符串提取出来放到单独的服务器上, 可以采用redis的方案 ,使用 setnx 加锁。
而单节点的redis服务器可能会宕机,那么所有的客户端都无法获得锁了,服务变得不可用,为了提高可用性可以给这个Redis节点增加Slave节点。但是由于Redis的数据同步无法做到实时,可能会丧失锁的安全性。
针对这个问题,antirez设计了Redlock算法,网上已有开源的实现。
总结
针对数据重复插入的问题,一般采用事务隔离或添加约束来防止数据重复,对于高并发的场景,可能会对数据库造成巨大的压力。传统单机部署可以采用synchronized锁解决问题,当业务发展,单节点性能不能满足的时候就需要做集群,部署到多态机器做负载均衡,在JVM内的共享常量池,就需要映射到外部。
当然,在具体的场景中,还要考虑很多因素,上述的方案不能完全满足,主要是提供一种思路,做一个参考。