最近在做聚划算商品的更新接口,商品有一个字段存储了商品的一些扩展属性,以键值对数组的形式存放,格式如下:
key1:value1;key2:value2;
在根据商品id对商品属性进行更新的时候,业务上需要把客户端传入的新的键值对数组和数据库中已经有的键值对数组进行合并,伪代码如下:
select old_attributes from table where primary_key = ? ---step1
attributes = merge(old_attributes,new_attributes) ----step2
update table set attributes_column = attributes where primary_key = ? ----step3
但是这样的话,存在一个丢失更新的问题,两个线程ThreadA 和 ThreadB 同时运行到了step1得到相同的old_attributes,
然后同时做step2,最后ThreadA先做step3,而ThreadB后做step3,这样ThreadB就把ThreadA的属性更新给丢失了!
目前因为接口的调用量比较小,还没有暴露出这个问题。
解决办法:
思路1:把对属性的更新变成串行操作,每个线程把自己要更新的attributes写入一个队列,由单线程从队列中读出属性,然后顺序更新到数据库记录。
缺点是把数据库更新操作进行了人为的分解,提高了代码的复杂度; 另外,属性的更新操作和其他更新操作被分离开来,没有保证事务。并且这种异步
更新的方式对一些实时性要求很高的场景(数据库更新后立即要读出的场景)不适用。
思路2: 给存在这种丢失更新的记录增加版本号,在对一行进行更新的时候 限制条件=主键+版本号,同时对记录的版本号进行更新。
伪代码如下:
start transaction;
select attributes, old_version from table where primary_key = ?
属性合并
update table set version = old_verison + 1 , attributes_column = attributes_value where primary_key = ? and version = old_version
commit;
事务提交以后,看最后一步更新操作的记录更新数是否为1,如果不是,则在业务上提示重试。(表明此时更新操作的并发度较高。)
目前,我们使用的Mysql 5的数据库隔离级别是repeatable read ,所谓可重复读,指的是事务A和事务B同时对一行进行更新,但是事务A的更新操作
在commit之前是不会反映到事务B中的,这满足了ACID特性中的Isolation(隔离性) 。
下面是一个具体的实验: (环境:1、操作系统:Mac Mountain Lion 2 、数据库: Mysql 5.5.29-log Source distribution)
数据库事务的隔离级别采用repeatable-read
事务A 事务B
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql > start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select count ,version from user where id=1;
+-------+---------+
| count | version |
+-------+---------+
| 9 | 6 |
+-------+---------+
1 row in set (0.00 sec)
mysql> select count ,version from user where id=1;
+-------+---------+
| count | version |
+-------+---------+
| 9 | 6 |
+-------+---------+
1 row in set (0.00 sec)
mysql> update user set count=10 ,version=7 where id =1 and version=6;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
(此时id=1这一行被加了间隙锁(即next-key lock) 后面会有说明)
mysql> update user set count=10 ,version=7 where id =1 and version=6;
(此更新操作会被阻塞)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
mysql> update user set count=10 ,version=7 where id =1 and version=6;
Query OK, 0 rows affected (3.28 sec)
Rows matched: 0 Changed: 0 Warnings: 0
(注意:此时,生效的行数是0,因为版本号已经更新成7了,但是接下来我们再次做
select 操作)
mysql> select * from user where id=1;
+----+------+-------+---------+
| id | name | count | version |
+----+------+-------+---------+
| 1 | NULL | 9 | 6 |
+----+------+-------+---------+
1 row in set (0.00 sec)
(仍然是事务A更新之前的值,但是前面的更新操作作用记录数是0,
这就是所谓的可重复读,但是更新操作的时候,是以db里面已经生效的版本 为依据的)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from user where id=1;
+----+------+-------+---------+
| id | name | count | version |
+----+------+-------+---------+
| 1 | NULL | 10 | 7 |
+----+------+-------+---------+
1 row in set (0.00 sec)
(提交事务B,再次查询,已经得到了事务A的提交) 。
而之前提到的所谓间隙锁(next-key lock),指的是一条更新操作,会锁住它控制的一个记录的范围,
比如:
事务A: 事务B:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> update user set version=6 where id < 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> insert into user(id) values(2);
(此事务会被阻塞)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into user(id) values(2);
Query OK, 1 row affected (4.15 sec)
我们可以看到所谓间隙锁,锁的范围是 闭区间,比如上面的例子 ,where条件是 id<2, 但是id=2的记录也是不能insert的。
我现在正在做的事情是把版本控制功能模块化,思路是:
1、通过配置文件告知版本控制模块哪个表的记录要做版本控制。
2、改写数据层,在需要进行版本控制的表的行的更新操作之前,注入一个行版本号的检查,如果DB中的版本比当前版本大,则此次更新操作失败 。
代码正在开发中。