在一个系统中,数据最重要的资产。在设计和实现一个系统的存储过程中,有哪些问题是要特别考虑的。
一个合格的系统系统,最基本的要求是什么?数据不能错。
一个流程,流程中每一个环节,都少不了更新数据,每一次更新操作又可能需要同时更新好几张表。这些操作可能被随机分布到很多台服务器上执行,服务器有可能故障,网络有可能出问题。
在这么复杂的情况下,保证数据一笔都不能错,是不是很难?实际上,只要掌握了方法,其实并不难。
首先,你的代码必须是正确没 Bug 的,如果说是因为代码 Bug 导致的数据错误,那谁也救不了你。
然后,你要会正确地使用数据库的事务。比如,你在提交业务表单的时候,同时要在多张表中插入数据,那这些插入数据的 INSERT 必须在一个数据库事务中执行,数据库的事务可以确保:执行这些 INSERT 语句,要么一起都成功,要么一起都失败。
还有一些情况下会引起数据错误,我们一起来看一下。不过在此之前,我们要明白,对于一个系统而言,它的核心功能和数据结构是怎样的。
多张业务表之间的关系大多是主表和后面的几个子表都是一对多的关系,关联的外键就是主表的主键。
绝大部分系统它的核心功能和数据结构都是这样的。
如何避免数据重复?
接下来我们来看一个场景。一个提供创建业务表单的 HTTP 接口,用户在浏览器页面上点击“提交”按钮的时候,浏览器就会给系统发一个请求,后端服务在收到请求之后,往数据库表插入一条数据。
假如用户点击“提交”的按钮时手一抖,点了两下,浏览器发了两个 HTTP 请求,结果是什么?创建了两条一模一样的数据。这样肯定不行,需要做防重。
有的同学会说,前端页面上应该防止用户重复提交表单,你说的没错。但是,网络错误会导致重传,很多 RPC 框架、网关都会有自动重试机制,所以对于服务来说,重复请求这个事儿,你是没办法完全避免的。
解决办法是,让你的服务具备幂等性。什么是幂等呢?一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,一个幂等的方法,使用同样的参数,对它进行调用多次和调用一次,对系统产生的影响是一样的。所以,对于幂等的方法,不用担心重复执行会对系统造成任何改变。一个幂等的服务,无论创建请求发送多少次,正确的结果是,数据库只有一条新创建的数据记录。
这里面有一个不太好解决的问题:对于服务来说,它怎么知道发过来的创建请求是不是重复请求呢?
在插入数据之前,先查询一下表里面有没有重复的数据,行不行?不太行,因为你很难用 SQL 的条件来定义“重复的数据”,比如用户一样、编号一样、名称一样,就认为是重复数据么?不一定,万一用户就是连续下了两个一模一样的业务表单呢?所以这个方法说起来容易,实际上很难实现。
很多电商解决这个问题的思路是这样的。在数据库的最佳实践中有一条就是,数据库的每个表都要有主键,绝大部分数据表都遵循这个最佳实践。一般来说,我们在往数据库插入一条记录的时候,都不提供主键,由数据库在插入的同时自动生成一个主键。这样重复的请求就会导致插入重复数据。
我们知道,表的主键自带唯一约束,如果我们在一条 INSERT 语句中提供了主键,并且这个主键的值在表中已经存在,那这条 INSERT 会执行失败,数据也不会被写入表中。我们可以利用数据库的这种“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建服务的幂等性问题。
具体的做法是这样的,我们给系统增加一个“生成唯一编号”的服务,这个服务没有参数,返回值就是一个新的、全局唯一的编号。在用户进入创建表单的页面时,前端页面先调用这个生成编号服务得到一个唯一编号,在用户提交的时候,在创建表单的请求中带着这个编号。
这个编号也是我们数据表的主键,这样,无论是用户手抖,还是各种情况导致的重试,这些重复请求中带的都是同一个编号。服务在数据表中插入数据的时候,执行的这些重复 INSERT 语句中的主键,也都是同一个编号。数据库的唯一约束就可以保证,只有一次 INSERT 语句是执行成功的,这样就实现了创建服务幂等性。
为了便于你理解,下面把上面这个幂等创建表单的流程,绘制成了时序图供你参考:
还有一点需要注意的是,如果是因为重复业务提交导致插入表失败,服务不要把这个错误返回给前端页面。否则,就有可能出现这样的情况:用户点击提交按钮后,页面提示业务提交失败,而实际上却成功了。正确的做法是,遇到这种情况,服务直接返回表单创建成功就可以了。
如何解决 ABA 问题?
同样,系统各种更新服务一样也要具备幂等性。
这些更新服务步骤中的更新操作,最终落到数据库上,都是对主表的 UPDATE 操作。数据库的更新操作,本身就具备天然的幂等性,比如说,你把业务状态,从未完成更新成已完成,执行一次和重复执行多次,状态都是已完成,不用我们做任何额外的逻辑,这就是天然幂等。
那在实现这些更新服务时,还有什么问题需要特别注意的吗?还真有,在并发环境下,你需要注意 ABA 问题。
什么是 ABA 问题呢?我举个例子你就明白了。比如说,需要更新字段A为 666,刚填完,发现填错了,又修改成 888。对服务来说,这就是 2 个更新的请求。
正常情况下,表单中的A字段会先更新成 666,再更新成 888,这是没问题的。
那不正常情况呢?666 请求到了,更新成 666,然后 888 请求到了,A又更新成 888,但是 666 更新成功的响应丢了,调用方没收到成功响应,自动重试,再次发起 666 请求,A又被更新成 666 了,这数据显然就错了。这就是非常有名的 ABA 问题。
ABA 问题怎么解决?这里给你提供一个比较通用的解决方法。给你的主表增加一列,列名可以叫 version,也即是“版本号”的意思。每次查询的时候,版本号需要随着数据返回给页面。页面在更新数据的请求中,需要把这个版本号作为更新请求的参数,再带回给更新服务。
服务在更新数据的时候,需要比较当前数据的版本号,是否和消息中的版本号一致,如果不一致就拒绝更新数据。如果版本号一致,还需要再更新数据的同时,把版本号 +1。“比较版本号、更新数据和版本号 +1”,这个过程必须在同一个事务里面执行。
具体的 SQL 可以这样来写:
UPDATE orders set tracking_number = 666, version = version + 1
WHERE version = 8;
在这条 SQL 的 WHERE 条件中,version 的值需要页面在更新的时候通过请求传进来。
通过这个版本号,就可以保证,从我打开这条记录开始,一直到我更新这条记录成功,这个期间没有其他人修改过这条数据。因为,如果有其他人修改过,数据库中的版本号就会改变,那我的更新操作就不会执行成功。我只能重新查询新版本的数据,然后再尝试更新。
有了这个版本号,再回头看一下我们上面那个 ABA 问题的例子,会出现什么结果?可能出现两种情况:
第一种情况,更新为 666 的操作成功了,更新为 888 的请求带着旧版本号,那就会更新失败,页面提示用户更新 888 失败。
第二种情况,666 更新成功后,888 带着新的版本号,888 更新成功。这时候即使重试的 666 请求再来,因为它和上一条 666 请求带着相同的版本号,上一条请求更新成功后,这个版本号已经变了,所以重试请求的更新必然失败。
无论哪种情况,数据库中的数据与页面上给用户的反馈都是一致的。这样就可以实现幂等更新并且避免了 ABA 问题。
小结
我们的内容是实现业务操作的幂等的方法。
因为网络、服务器等等这些不确定的因素,重试请求是普遍存在并且不可避免的。具有幂等性的服务可以完美地克服重试导致的数据错误。
对于创建服务来说,可以通过预先生成编号,然后利用数据库中编号的唯一约束这个特性,避免重复写入数据,实现创建服务的幂等性。对于更新服务,可以通过一个版本号机制,每次更新数据前校验版本号,更新数据同时自增版本号,这样的方式,来解决 ABA 问题,确保更新服务的幂等性。
通过这样两种幂等的实现方法,就可以保证,无论请求是不是重复,表中的数据都是正确的。当然,上面讲到的实现幂等的方法,你完全可以套用在其他需要实现幂等的服务中,只需要这个服务操作的数据保存在数据库中,并且有一张带有主键的数据表就可以了。