gitbook/后端存储实战课/docs/204673.md
2022-09-03 22:05:03 +08:00

125 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 01 | 创建和更新订单时,如何保证数据准确无误?
你好,我是李玥。
订单系统是整个电商系统中最重要的一个子系统,订单数据也就是电商企业最重要的数据资产。今天这节课,我来和你说一下,在设计和实现一个订单系统的存储过程中,有哪些问题是要特别考虑的。
一个合格的订单系统,最基本的要求是什么?**数据不能错。**
一个购物流程,从下单开始、支付、发货,直到收货,这么长的一个流程中,每一个环节,都少不了更新订单数据,每一次更新操作又需要同时更新好几张表。这些操作可能被随机分布到很多台服务器上执行,服务器有可能故障,网络有可能出问题。
在这么复杂的情况下,保证订单数据一笔都不能错,是不是很难?实际上,只要掌握了方法,其实并不难。
* 首先你的代码必须是正确没Bug的如果说是因为代码Bug导致的数据错误那谁也救不了你。
* 然后你要会正确地使用数据库的事务。比如你在创建订单的时候同时要在订单表和订单商品表中插入数据那这些插入数据的INSERT必须在一个数据库事务中执行数据库的事务可以确保执行这些INSERT语句要么一起都成功要么一起都失败。
我相信这些“基本操作”对于你来说,应该不是问题。
但是,还有一些情况下会引起数据错误,我们一起来看一下。不过在此之前,我们要明白,对于一个订单系统而言,它的核心功能和数据结构是怎样的。
因为,任何一个电商,它的订单系统的功能都是独一无二的,基于它的业务,有非常多的功能,并且都很复杂。我们在讨论订单系统的存储问题时,必须得化繁为简,只聚焦那些最核心的、共通的业务和功能上,并且以这个为基础来讨论存储技术问题。
## 订单系统的核心功能和数据
我先和你简单梳理一下一个订单系统必备的功能,它包含但远远不限于:
1. 创建订单;
2. 随着购物流程更新订单状态;
3. 查询订单,包括用订单数据生成各种报表。
为了支撑这些必备功能,在数据库中,我们至少需要有这样几张表:
1. 订单主表:也叫订单表,保存订单的基本信息。
2. 订单商品表:保存订单中的商品信息。
3. 订单支付表:保存订单的支付和退款信息。
4. 订单优惠表:保存订单使用的所有优惠信息。
这几个表之间的关系是这样的:订单主表和后面的几个子表都是一对多的关系,关联的外键就是订单主表的主键,也就是订单号。
绝大部分订单系统它的核心功能和数据结构都是这样的。
## 如何避免重复下单?
接下来我们来看一个场景。一个订单系统提供创建订单的HTTP接口用户在浏览器页面上点击“提交订单”按钮的时候浏览器就会给订单系统发一个创建订单的请求订单系统的后端服务在收到请求之后往数据库的订单表插入一条订单数据创建订单成功。
假如说用户点击“创建订单”的按钮时手一抖点了两下浏览器发了两个HTTP请求结果是什么创建了两条一模一样的订单。这样肯定不行需要做防重。
有的同学会说前端页面上应该防止用户重复提交表单你说的没错。但是网络错误会导致重传很多RPC框架、网关都会有自动重试机制所以对于订单服务来说重复请求这个事儿你是没办法完全避免的。
解决办法是,**让你的订单服务具备幂等性。**什么是幂等呢?一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,一个幂等的方法,使用同样的参数,对它进行调用多次和调用一次,对系统产生的影响是一样的。所以,对于幂等的方法,不用担心重复执行会对系统造成任何改变。一个幂等的创建订单服务,无论创建订单的请求发送多少次,正确的结果是,数据库只有一条新创建的订单记录。
这里面有一个不太好解决的问题:对于订单服务来说,它怎么知道发过来的创建订单请求是不是重复请求呢?
在插入订单数据之前先查询一下订单表里面有没有重复的订单行不行不太行因为你很难用SQL的条件来定义“重复的订单”订单用户一样、商品一样、价格一样就认为是重复订单么不一定万一用户就是连续下了两个一模一样的订单呢所以这个方法说起来容易实际上很难实现。
很多电商解决这个问题的思路是这样的。在数据库的最佳实践中有一条就是,数据库的每个表都要有主键,绝大部分数据表都遵循这个最佳实践。一般来说,我们在往数据库插入一条记录的时候,都不提供主键,由数据库在插入的同时自动生成一个主键。这样重复的请求就会导致插入重复数据。
我们知道表的主键自带唯一约束如果我们在一条INSERT语句中提供了主键并且这个主键的值在表中已经存在那这条INSERT会执行失败数据也不会被写入表中。**我们可以利用数据库的这种“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。**
具体的做法是这样的,我们给订单系统增加一个“生成订单号”的服务,这个服务没有参数,返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时,前端页面先调用这个生成订单号服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号。
这个订单号也是我们订单表的主键这样无论是用户手抖还是各种情况导致的重试这些重复请求中带的都是同一个订单号。订单服务在订单表中插入数据的时候执行的这些重复INSERT语句中的主键也都是同一个订单号。数据库的唯一约束就可以保证只有一次INSERT语句是执行成功的这样就实现了创建订单服务幂等性。
为了便于你理解,我把上面这个幂等创建订单的流程,绘制成了时序图供你参考:
![](https://static001.geekbang.org/resource/image/66/17/667089ecbfdf18733c83c3d07783fa17.jpg)
还有一点需要注意的是,如果是因为重复订单导致插入订单表失败,订单服务不要把这个错误返回给前端页面。否则,就有可能出现这样的情况:用户点击创建订单按钮后,页面提示创建订单失败,而实际上订单却创建成功了。正确的做法是,遇到这种情况,订单服务直接返回订单创建成功就可以了。
## 如何解决ABA问题
同样,订单系统各种更新订单的服务一样也要具备幂等性。
这些更新订单服务比如说支付、发货等等这些步骤中的更新订单操作最终落到订单库上都是对订单主表的UPDATE操作。数据库的更新操作本身就具备天然的幂等性比如说你把订单状态从未支付更新成已支付执行一次和重复执行多次订单状态都是已支付不用我们做任何额外的逻辑这就是天然幂等。
那在实现这些更新订单服务时还有什么问题需要特别注意的吗还真有在并发环境下你需要注意ABA问题。
什么是ABA问题呢我举个例子你就明白了。比如说订单支付之后小二要发货发货完成后要填个快递单号。假设说小二填了一个单号666刚填完发现填错了赶紧再修改成888。对订单服务来说这就是2个更新订单的请求。
正常情况下订单中的快递单号会先更新成666再更新成888这是没问题的。那不正常情况呢666请求到了单号更新成666然后888请求到了单号又更新成888但是666更新成功的响应丢了调用方没收到成功响应自动重试再次发起666请求单号又被更新成666了这数据显然就错了。这就是非常有名的ABA问题。
具体的时序你可以参考下面这张时序图:
![](https://static001.geekbang.org/resource/image/60/f5/6007b4d5a6e804e755e91c5f1d3cd2f5.jpg)
ABA问题怎么解决这里给你提供一个比较通用的解决方法。给你的订单主表增加一列列名可以叫version也即是“版本号”的意思。每次查询订单的时候版本号需要随着订单数据返回给页面。页面在更新数据的请求中需要把这个版本号作为更新请求的参数再带回给订单更新服务。
订单服务在更新数据的时候,需要比较订单当前数据的版本号,是否和消息中的版本号一致,如果不一致就拒绝更新数据。如果版本号一致,还需要再更新数据的同时,把版本号+1。“比较版本号、更新数据和版本号+1”这个过程必须在同一个事务里面执行。
具体的SQL可以这样来写
```
UPDATE orders set tracking_number = 666, version = version + 1
WHERE version = 8;
```
在这条SQL的WHERE条件中version的值需要页面在更新的时候通过请求传进来。
通过这个版本号,就可以保证,从我打开这条订单记录开始,一直到我更新这条订单记录成功,这个期间没有其他人修改过这条订单数据。因为,如果有其他人修改过,数据库中的版本号就会改变,那我的更新操作就不会执行成功。我只能重新查询新版本的订单数据,然后再尝试更新。
有了这个版本号再回头看一下我们上面那个ABA问题的例子会出现什么结果可能出现两种情况
1. 第一种情况把运单号更新为666的操作成功了更新为888的请求带着旧版本号那就会更新失败页面提示用户更新888失败。
2. 第二种情况666更新成功后888带着新的版本号888更新成功。这时候即使重试的666请求再来因为它和上一条666请求带着相同的版本号上一条请求更新成功后这个版本号已经变了所以重试请求的更新必然失败。
无论哪种情况数据库中的数据与页面上给用户的反馈都是一致的。这样就可以实现幂等更新并且避免了ABA问题。下图展示的是第一种情况第二种情况也是差不多的
![](https://static001.geekbang.org/resource/image/02/a5/02497bcdaf0e37a7e6f92d180a4c38a5.jpg)
## 小结
我们把今天这节课的内容做一个总结。今天这节课,实际上就讲了一个事儿,也就是,实现订单操作的幂等的方法。
因为网络、服务器等等这些不确定的因素,重试请求是普遍存在并且不可避免的。具有幂等性的服务可以完美地克服重试导致的数据错误。
对于创建订单服务来说可以通过预先生成订单号然后利用数据库中订单号的唯一约束这个特性避免重复写入订单实现创建订单服务的幂等性。对于更新订单服务可以通过一个版本号机制每次更新数据前校验版本号更新数据同时自增版本号这样的方式来解决ABA问题确保更新订单服务的幂等性。
通过这样两种幂等的实现方法,就可以保证,无论请求是不是重复,订单表中的数据都是正确的。当然,上面讲到的实现订单幂等的方法,你完全可以套用在其他需要实现幂等的服务中,只需要这个服务操作的数据保存在数据库中,并且有一张带有主键的数据表就可以了。
## 思考题
实现服务幂等的方法,远不止我们这节课上介绍的这两种,课后请你想一下,在你负责开发的业务系统中,能不能用这节课中讲到的方法来实现幂等?除了这两种方法以外,还有哪些实现服务幂等的方法?欢迎你在留言区与我交流互动。
感谢你的阅读,如果你觉得今天的内容对你有所帮助,也欢迎把它分享给你的朋友。