后端存储架构设计迷思
后端存储架构设计
来源“极客时间” 《后端存储实战课》,感觉在实际项目设计中很有用。
01 业务场景分析
以设计电商系统为例。首先分析电商系统的业务模块,画UML图。电商系统面对的使用者包括:用户、老板、运营人员。
- 用户:浏览和购买商品,
- 运营人员:进货、卖货、维护商品信息、管理订单;
- 老板:管理报表
电商系统的核心功能是购物,流程为:
用户浏览商品 -> 加入购物车 -> 下单 -> 支付 -> 商家发货 -> 用户收货
可以画一个时序图感受一下。
另外,电商系统的主要模块还包括:
- 商品:维护和展示商品信息和价格。
- 订单:维护订单信息和订单状态,计算订单金额。
- 购物车:维护用户购物车中的商品。
- 支付:负责与系统内外部的支付渠道对接,实现支付功能。
- 库存:维护商品的库存数量和库存信息。
- 促销:制定促销规则,计算促销优惠。
- 用户:维护系统的用户信息,注意用户模块它是一个业务模块,一般不负责用户登录和认证,这是两个完全不同的功能。
- 账户:负责维护用户的账户余额。
- 搜索推荐:负责商城中,搜索商品和各种列表页和促销页的组织和展示,简单的说就是决定让用户优先看到哪些商品。
- 报表:实现统计和分析功能,生成报表,给老板来做经营分析和决策使用。
对于一个电商系统,正确使用数据库事务是必要的。
02 考虑问题1:用户重复下单,怎么办?
一个订单系统,提供创建订单的HTTP接口,用户在浏览器页面上点击“提交订单”按钮的时候,浏览器就会给订单系统发一个创建订单的请求,订单系统的后端服务,在收到请求之后,往数据库的订单表插入一条订单数据,创建订单成功。
假如说,用户点击“创建订单”的按钮时手一抖,点了两下,浏览器发了两个HTTP请求,结果是什么?创建了两条一模一样的订单。这样肯定不行,需要做防重。
有的同学会说,前端页面上应该防止用户重复提交表单,你说的没错。但是,网络错误会导致重传,很多RPC框架、网关都会有自动重试机制,所以对于订单服务来说,重复请求这个事儿,你是没办法完全避免的。
解决办法是,让你的订单服务具备幂等性。什么是幂等呢?一个幂等操作的特点是,其任意多次执行所产生的影响均与一次执行的影响相同。也就是说,一个幂等的方法,使用同样的参数,对它进行调用多次和调用一次,对系统产生的影响是一样的。所以,对于幂等的方法,不用担心重复执行会对系统造成任何改变。一个幂等的创建订单服务,无论创建订单的请求发送多少次,正确的结果是,数据库只有一条新创建的订单记录。
如何实现幂等?
一般来说,对于订单的插入操作,服务中只传入实体VO,而不指定主键。在mapper层插入数据库时,由数据库自动生成一个主键。这样的数据库编写规范会导致重复数据的产生,并且sql去重比较困难。
我们知道,表的主键自带唯一约束,如果我们在一条INSERT语句中提供了主键,并且这个主键的值在表中已经存在,那这条INSERT会执行失败,数据也不会被写入表中。我们可以利用数据库的这种“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。
具体的做法是这样的,我们给订单系统增加一个“生成订单号”的服务,这个服务没有参数,返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时,前端页面先调用这个生成订单号服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号。
这个订单号也是我们订单表的主键,这样,无论是用户手抖,还是各种情况导致的重试,这些重复请求中带的都是同一个订单号。订单服务在订单表中插入数据的时候,执行的这些重复INSERT语句中的主键,也都是同一个订单号。数据库的唯一约束就可以保证,只有一次INSERT语句是执行成功的,这样就实现了创建订单服务幂等性。
还有一点需要注意的是,如果是因为重复订单导致插入订单表失败,订单服务不要把这个错误返回给前端页面。否则,就有可能出现这样的情况:用户点击创建订单按钮后,页面提示创建订单失败,而实际上订单却创建成功了。正确的做法是,遇到这种情况,订单服务直接返回订单创建成功就可以了。
03 考虑问题2:ABA问题
关于网络延迟问题:考虑修改订单的业务场景,如果有一条修改请求A:将订单号改为666,和另一条修改请求B:将订单号修改为888。对于网络断线重连的情况,可能会重发请求,导致结果不一致。这个问题怎么解决?
ABA问题怎么解决?这里给你提供一个比较通用的解决方法。给你的订单主表增加一列,列名可以叫version,也即是“版本号”的意思。每次查询订单的时候,版本号需要随着订单数据返回给页面。页面在更新数据的请求中,需要把这个版本号作为更新请求的参数,再带回给订单更新服务。
订单服务在更新数据的时候,需要比较订单当前数据的版本号,是否和消息中的版本号一致,如果不一致就拒绝更新数据。如果版本号一致,还需要再更新数据的同时,把版本号+1。“比较版本号、更新数据和版本号+1”,这个过程必须在同一个事务里面执行。
具体的SQL可以这样来写:
UPDATE orders set tracking_number = 666, version = version + 1
WHERE version = 8;
04 访问频繁、大流量的商品详情页设计
-
使用数据库缓存,抵挡绝大部分读请求;
-
使用mongoDb存储商品参数,从而跨商品类别存储;
-
使用对象存储保存图片和视频;
- 图片和视频由于占用存储空间比较大,一般的存储方式都是,在数据库中只保存图片视频的ID或者URL,实际的图片视频以文件的方式单独存储。
- 现在图片和视频存储技术已经非常成熟了,首选的方式就是保存在对象存储(Object Storage)中。各大云厂商都提供对象存储服务,比如国内的七牛云、AWS的S3等等,也有开源的对象存储产品,比如MinIO,可以私有化部署。虽然每个产品的API都不一样,但功能大同小异。
- 对象存储可以简单理解为一个无限容量的大文件KV存储,它的存储单位是对象,其实就是文件,可以是一张图片,一个视频,也可以是其他任何文件。每个对象都有一个唯一的key,利用这个key就可以随时访问对应的对象。基本的功能就是写入、访问和删除对象。
- 云服务厂商的对象存储大多都提供了客户端API,可以在Web页面或者App中直接访问而不用通过后端服务来中转。这样,App和页面在上传图片视频的时候,直接保存到对象存储中,然后把对应key保存在商品系统中就可以了。
-
将商品介绍静态化
05 事务实现小技巧
并发下的隔离
对于电商系统而言,使用数据库事务是基本的处理方法。首先需要确定数据库事务的隔离级别。
- RC(READ COMMITED):能够看到已提交的事务
- RR(READ REPEATED):可重复读,在事务提交以前看到的都是事务begin的时候的。
一般用RC或者RR相对更安全,另外两种隔离级别不常用。有一种通用的方法可以让电商系统的事务处理更安全合理,更能够应对并发场景:
- 我们给账户余额表增加一个log_id属性,记录最后一笔交易的流水号。
- 首先开启事务,查询并记录当前账户的余额和最后一笔交易的流水号。
- 然后写入流水记录。
- 再更新账户余额,需要在更新语句的WHERE条件中限定,只有流水号等于之前查询出的流水号时才更新。
- 然后检查更新余额的返回值,如果更新成功就提交事务,否则回滚事务。
这种方法对于RC和RR都适用。
需要特别注意的一点是,更新账户余额后,不能只检查更新语句是不是执行成功了,还需要检查返回值中变更的行数是不是等于1。因为即使流水号不相等,余额没有更新,这条更新语句的执行结果仍然是成功的,只是更新了0条记录。
分布式事务:保证多个系统数据一致性
对于多个系统之间协调使用的事务场景,就需要使用分布式事务。这里的“分布式”当然不是多台机器的意思,只是系统的多个模块,或者多个系统。单一系统内部是不需要的。
事务需要ACID,但是连数据库事务都未必完全满足ACID,分布式事务当然也不会完全满足。因此,在没有完整的ACID的情况下,就需要进行其他算法的协调。重点介绍2PC,还有3PC,TCC等
2PC:两阶段提交
考虑业务场景为:用户在订单中添加优惠券。这个过程涉及到订单系统、促销系统两部分的连接。一个事务跨越多个系统,就需要分布式事务参与。
订单系统需要:
- 在“订单优惠券表”中写入订单关联的优惠券数据;
- 在“订单表”中写入订单数据。
订单系统内两个操作的一致性问题可以直接使用数据库事务来解决。促销系统需要操作就比较简单,把刚刚使用的那张优惠券的状态更新成“已使用”就可以了。我们需要这两个系统的数据更新操作保持一致,要么都更新成功,要么都更新失败。
2PC引入一个事务协调者的角色,来协调订单系统和促销系统,协调者对客户端提供一个完整的“使用优惠券下单”的服务,在这个服务的内部,协调者再分别调用订单和促销的相应服务。两阶段提交指的是准备阶段和提交阶段。在准备阶段,事务协调者协调多个系统,都进入准备阶段后,整体再进入提交阶段。在准备阶段,如果任何一步出现错误或者是超时,协调者就会给两个系统发送“回滚事务”请求。每个系统在收到请求之后,回滚自己的数据库事务,分布式事务执行失败,两个系统的数据库事务都回滚了,相关的所有数据回滚到分布式事务执行之前的状态,就像这个分布式事务没有执行一样。
如果准备阶段成功,进入提交阶段,这个时候就“只有华山一条路”,整个分布式事务只能成功,不能失败。
如果发生网络传输失败的情况,需要反复重试,直到提交成功为止。如果这个阶段发生宕机,包括两个数据库宕机或者订单服务、促销服务所在的节点宕机,还是有可能出现订单库完成了提交,但促销库因为宕机自动回滚,导致数据不一致的情况。但是,因为提交的过程非常简单,执行很快,出现这种情况的概率非常小,所以,从实用的角度来说,2PC这种分布式事务的方法,实际的数据一致性还是非常好的。
在实现2PC的时候,没必要单独启动一个事务协调服务,这个协调服务的工作最好和订单服务或者优惠券服务放在同一个进程里面,这样做有两个好处:
参与分布式事务的进程更少,故障点也就更少,稳定性更好;
减少了一些远程调用,性能也更好一些。
2PC是一种强一致的设计,它可以保证原子性和隔离性。只要2PC事务完成,订单库和促销库中的数据一定是一致的状态,也就是我们总说的,要么都成功,要么都失败。
所以2PC比较适合那些对数据一致性要求比较高的场景,比如我们这个订单优惠券的场景,如果一致性保证不好,有可能会被黑产利用,一张优惠券反复使用,那样我们的损失就大了。
2PC也有很明显的缺陷,整个事务的执行过程需要阻塞服务端的线程和数据库的会话,所以,2PC在并发场景下的性能不会很高。并且,协调者是一个单点,一旦过程中协调者宕机,就会导致订单库或者促销库的事务会话一直卡在等待提交阶段,直到事务超时自动回滚。
卡住的这段时间内,数据库有可能会锁住一些数据,服务中会卡住一个数据库连接和线程,这些都会造成系统性能严重下降,甚至整个服务被卡住。
所以,只有在需要强一致、并且并发量不大的场景下,才考虑使用2PC。
06 搜索功能的实现:elasticSearch
elasticSearch是一种全文搜索数据库。其索引采用了倒排索引的机制,首先将内容进行分词,然后进行倒排索引。因此对内容的部分分词进行搜索,效果比较好。在搜索时,es也会自动把搜索条件也分词以后进行匹配。
但是,倒排索引相比于一般数据库采用的B树索引,它的写入和更新性能都比较差,因此倒排索引也只是适合全文搜索,不适合更新频繁的交易类数据。
07 应对大量订单数据:归档历史数据
订单数据具有热尾效应,往往大量访问的数据都是最近新增的数据,历史数据几乎很少被访问到。因而可以对历史数据进行归档。
具体的实现:
- 首先我们需要创建一个和订单表结构一模一样的历史订单表;
- 然后,把订单表中的历史订单数据分批查出来,插入到历史订单表中去。这个过程你怎么实现都可以,用存储过程、写个脚本或者写个导数据的小程序都行,用你最熟悉的方法就行。如果你的数据库已经做了主从分离,那最好是去从库查询订单,再写到主库的历史订单表中去,这样对主库的压力会小一点儿。
- 现在,订单表和历史订单表都有历史订单数据,先不要着急去删除订单表中的数据,你应该测试和上线支持历史订单表的新版本代码。因为两个表都有历史订单,所以现在这个数据库可以支持新旧两个版本的代码,如果新版本的代码有Bug,你还可以立刻回滚到旧版本,不至于影响线上业务。
- 等新版本代码上线并验证无误之后,就可以删除订单表中的历史订单数据了。
- 最后,还需要上线一个迁移数据的程序或者脚本,定期把过期的订单从订单表搬到历史订单表中去。
浙公网安备 33010602011771号