由浅入深的介绍扣减业务中的一些高并发构建方案(中)

2023年 8月 1日 27.3k 0

前言

大家好,我是路由器没有路。

在上一讲的实现方案里,我们讨论采用数据库的扣减实现方案,如果以常规的机器或者 Docker 来进行评估,此方案将来实现单机级的 TPS

之所以介绍,是要告诉你架构是面向业务功能、成本、实现难度、时间等因素的取舍,而不是绝对的追求高性能、高并发及高可用等非功能性指标。

另外。在上一讲里介绍的扣减业务的技术实现方案有一定的需求基础介绍了。因此今天我们讲解的方案将直接复用以上信息,不再赘述。

有忘记的同学,可以查看这篇文章:《由浅入深的介绍扣减业务中的一些高并发构建方案(上)》。

另外,大家可以关注公众号:Go键盘侠,在上面会第一时间更新编程技术、开发经验的分享文章,回复“Redis”可免费领取《Redis 核心技术与实战》资料。

这一讲我将由浅入深的介绍如何基于缓存来实现单机万级这些并发扣减目标。

引入缓存方案的原因

数据库的方案虽然避免了超卖和少卖的情况。但因采用了事物的方式保证一致性和原子性,所以在 SKU 数量较多时,性能下降较明显。

在学习下面的内容之前,我们先来回顾一下事物本质上的四个特点,ACID,分别是原子性、一致性、隔离性及持久性。

我们知道扣减有一个要求,就当一个 SKU 购买的数量不够时,整个批量扣减就要回滚,因此我们需要使用类似循环的方式对每一个扣减的返回值进行检查。

另外一个原因是,当多个用户一个 SKU 的性能也并不乐观,因为当出现高并发扣减或者并发扣减同一个 SKU 时,事物的隔离性会导致加锁、等待释放锁情况出现。

首先你要知道,扣减只需要保证原子性即可,并不需要数据库提供的 ACID。另外,在此基础上该如何提升性能?

在不改变机器配置的情况下,把传统的 SQL 类数据库替换为性能更好的 NoSQL 类数据存储试试。是不是有一个性能又好,同时又能够满足扣减多个 SQL 具有原子性的 NoSQL 数据库了?答案显然是可以的。

Redis 做缓存

Redis 作为最近几年非常流行的 NoSQL 数据库,它的原始版本或者改造版本基本上已经被国内所有互联网公司或者云厂商所采用。

不管是微博暴敛事件的流量应对,还是电商的大促流量处理,它的踪影无处不在。

它的高性能上的能力是首屈一指,另外因为是开源软件,且架构简单,布置在普通 Docker 即可,成本非常低。此外,Redis 采用了单线程的事件模型保障我们对于原子性的要求。

对于单线程的事件模型,简单的比喻就是说,当我们多个客户端给同时发送命令后,会按接收到的顺序进行串行的执行。对已经接收而未能执行的命令,只能排队等待。

基于此特点,当我们的扣减请求的 Redis 执行时,也就是原子性的。此特性刚好符合我们对扣减原子性的要求。

加入缓存方案实现

在确定了使用缓存来完成扣减的高性能后,这里我们结合扣减服务的整体架构图来进行进一步的分析,如下图所示:

上图中的扣减服务和上个案例的扣减服务一样,都提供了三个在线接口,但此时扣减服务依赖的是 Redis 缓存,而不是数据库了。

我们顺着上一讲的思路,继续以库存为场景,讲解扣减服务的实现。

缓存中存储的信息和上一展中的数据库表结构基本类似,包含当前商品和剩余的库存数量和当次的库减流水。

这里要注意两点:

  • 因为扣减全部依赖于缓存,而不依赖于数据库,所有存储与的数据均不设置过期并全量存储
  • 其次是以 KV 结构为主,伴随 hash、set 等结构,与 MySQL 表+行为主的结构有一定的差异

Redis 中的库存数量结构大致如下:

当我们存储的 SKU 有上百万千万级别时,此方式可以其他的降低存储空间,从而降低成本,毕竟内存是比较昂贵的。

对于 Redis 中存储的流水表采用哈希结构,即 key+hashField+hashValue ,结构大概如下:

我们在上一讲里介绍过,扣减接口支持一次扣减多个 SKU 数量。

查询 Redis 的命令文档时,你会发现,首先对于哈希结构不支持多个 key 的批量操作,其次对于不同数据结构间不支批量操作。

如果对于多个 SKU 不支批量操作,我们就需要按个 SKU 的命令扣减必须要发起多次对命令才可完成。这样,上面提到的单线程来保证扣件的原则性,此时则满足不了了。

使用 Lua 脚本实现原子批量操作

针对上述问题,我们可以采用 Lua 脚本来实现批量的单线程求。

Lua 是一个类似 JavaScript 的语言,它可以完成 Redis 已有命令不支持的功能。

Lua 脚本编写完之后将此脚本上传至 Redis 服务器,服务器会返回一个标识码代替此脚本。在实际执行具体请求时,将数据和此标识码发送出即可。

Redis 会和执行普通命令一样,采用单线程执行此脚本和对应数据。

当用户调用扣减接口时,将扣减 SKU 及对应数量脚本标识传递到 Redis 即可。

所有的扣减判断逻辑在 Redis 中的 Lua 脚本中执行,Lua 脚本执行完成之后返回是否成功给客户端。

当请求发送到后,Lua 脚本执行流程如下图案所示:

当 Redis 扣减成功后,扣减接口会异步的将此次扣减内容保存至数据库。保存数据库的目的是防止出现极端情况,宕机后数据为持续化的磁盘,此时我们可以使用数据库恢复或者校准数据。

最后,在存缓存的架构中还有一个运营后台,它直接连接到数据库,是运营和商家修改库存的入口。

当商品补齐了新的货物时,商家在运营后台将此 SKU 库存数量加回,同时运营后台的实现需要将此数量同步的增加至 Redis。

因为当前方案的所有实际扣减都在 Redis 中。

至此,新引入缓存扣减的基本方案已经介绍结束了。目前这个方案已经可以满足成单机万级的扣减了,下面我们再来看看如何应对异常情况。

异常场景分析

因为不支持 ACID 特性,导致在使用进行扣减时,相比数据库方案有叫多异常场景需要处理,此处我挑选几个重要的场景给你讲解。

Redis 宕机的场景

异常场景描述

如果 Redis 宕机时,Redis 中的 Lua 脚本执行到了扣减逻辑,并做了实际的扣减,则出现数据丢失的情况。

因为 Redis 没有事务的保证,已经扣减的数量不会回滚,宕机导致扣减服务给客户返回扣减失败,但实际上已经扣减了部分数据,并刷新了磁盘。

解决方案

当此 Redis 故障处理完成,再次启动后。部分库存数量已经丢失了,为了解决此问题,可以使用数据库中的数据进行校准。

常见方式是开发对账程序,通过对比 Redis 中与数据库中的数据是否一致,并结合减服务的日志,当发现数据不一致,同时日志记录扣减失败时,可以将数据库比 Redis 多的库存数据在中进行加回。

Redis 扣减成功后,异步刷新数据库失败

异常场景描述

第二个异常场景是扣减 Redis 完成并成功返回给客户后,异步刷新数据库失败的情况。

Redis 中的数据是准的,但数据库中的库存数据是多的。

解决方案

在结合库减服务日志确定是扣减成功,但异步记录数据失败后,可以将数据库比 Redis 多的库存数据在数据库中进行扣减。

缓存实现方案升级

上述的存缓存方案在使用了进行扣减实现后,基本上满足了扣减的高性能、高并发,满足我们最初的需求,那整体方案上还有哪些可以优化的空间呢?

在前面我们介绍过,扣减服务不仅包含扣减接口,还包含数量查询接口。

需优化的点

  • 查询接口的量级小,比写接口至少是十倍以上,即使使用了缓存进行卡掉,但读写都请求了同一个 Redis,将会导致扣减请求被读影响。

  • 其次,运营在后台进行操作,增加或者修改库存时,是在修改完数据库之后,在代码中异步修改刷新。

优化改造方案

改造方案一

增加一个从节点,在扣件服务里,根据请求类型路由到不同的 Redis 节点。

使用储存分离的好处是:不用太多的数据同步开发,直接使用 Redis 同步方案,成本低,开发量小。

改造方案二

第二个是运营后台修改数据库数量后,同步至 Redis 的逻辑,使用 binlog 进行处理。

关于如何接入和使用 binlog,你可以参见《Canal 中间件同步 MySql 数据到 ElasticSearch》的内容,同步到 Redis 的原理是类似的。

方案升级后实现的结构

优化后的整体方案如下所示:

相比于纯数据库互联方案,纯缓存方案也存在一定的优缺点和适用性。

场景和适用性分析

优点

缓存方案的主要优点是性能提升明显,使用缓存的扩建方案在保证了扩建的原则性和一致性等功能性要求之外,相比纯数据库的扩建方案至少提升十倍以上。

缺点

除了优点之外,存款存的方案同样存在一些缺点及其他一些缓存实现,为了高性能,并没有实现数据库的 ACID 特性。

导致在极端情况下可能会出现数据丢失,进而产生少卖。

另外,为了保证不出现少卖存款存的方案,需要做很多的对账异常处理的设计细则,复杂度会大幅增加。

适用场景

对于纯缓存的扣减的优缺点有了一定了解后,可以发现纯缓存在抗并发流量时效果非常显著。

因此它较适合应用于高并发、大流量的互联网场景。

但在极端情况下可能会出现一些数据的丢失,因此它优先适合对于数据精度不是特别苛刻的场景,比如用户购买限制等。

但如果上述的异常场景都降级方案应对,保证最终一致性,它也是可以应用在库存扣减、积分扣减的场景的。

在我所经历很了解的实战中,是有很多公司将此方案应用在非常精准的场景的。

总结

在上一讲《由浅入深的介绍扣减业务中的一些高并发构建方案(上)》中的数据库方案无法满足量级要求时。

本讲介绍了加入缓存的扩展方案,着重讲解了为什么缓存可以满足扩建的功能性要求。

对于分析的过程,希望你能够理解并应用,而不是关注最终提出的方案。

作为一名优秀的开发人员,你要知道,架构图是一个最终态,是静止的,它并不能 100%直接应用到你所面对的场景,而复习思路是可以复制和模仿的。

其次,本讲也分析了存款存方案存在一些异常场景,在实践中,正常流程是简单的还异常的,流程的思考与处理十分的复杂与繁琐,同时也最能体现技术性。

最后希望大家都有所收获。

思考题:

如果此时是一个集群,而不是个单独实例,又该如何演化和优化此方案?

可以把你的想法、思路或者总结写在评论区,我们一起交流、讨论。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论