不可错过的电商系统干货
电商作为互联网的常青业务,已经渗透到我们生活日常方方面面。随着市场发展,以及我们的个性化需求,衍化出很多玩法。虽然业务规则上略有差异,但底层技术都是相通的。无非就是领域建模、系统架构、微服务拆分、缓存设计、海量数据分表。涉及到的功能模块主要分为:店铺、商品、会员、营销、购物车、交易、库存、支付、物流、履约、售后、评价等。
关于高并发、高性能、高可用、易扩展等技能知识,市面资料已经非常多了。本文更多还是结合电商业务特性,讲解日常开发工作可能遇到的难题,给大家提供一些解决思路,避免设计缺陷,引发线上问题。
避免重复下单
业务早期,用户访问量并不大,订单存储基本还是采用单表支撑。我们在往数据库插入一条记录的时候,不提供主键,由数据库在插入的同时自动生成一个主键。这样重复的请求就会导致插入重复数据。
为了解决这个问题,我们使用数据库的“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。于是会引入一个“生成订单号”服务,返回一个全局唯一id。
现在的系统基本都是前后端分离,如何识别是一个下单渲染页向后端发起了两次请求?还是同一个用户对同一件商品先后下了两个订单?
一种方案是前端通过js脚本控制。但是无法解决用户刷新提交的请求。另外也无法解决恶意提交。
另一种是前后约定附加参数校验,当用户点击购买按钮时,渲染下单页面,展示商品、收货地址、运费、价格等信息,同时页面会埋上token信息,用户提交订单时,后端业务逻辑会校验token,有且匹配才认为是合理请求。注意:同一个token只能用一次。
<form action="/add-name-v2" method="post">
{% csrf_token %}
<input type="text" name="name">
<input type="submit" value="提交">
</form>
引申玩法:
有A和B两个系统,A系统调用B系统完成数据创建,如何识别重复调用问题?
方案一:
B接口规范中提供幂等参数定义,由调用方A传入指定业务唯一属性id,B系统接到请求,会根据预先定义的幂等字段做请求的重复判断。
优点:一次请求完成调用
缺点:A系统要提供全局性唯一id,且要细化到具体的每一次请求维度。
方案二:
B系统提供两个接口,A系统先请求第一个接口,获取幂等性字段,如商品id。然后再请求第二个业务接口且带上商品id,用户B系统负责请求重复判断。
优点:一次业务操作需要两次请求
缺点:提前申请商品id,可能存在浪费
其他方面:
如果更新涉及ABA问题,可以考虑引入version字段,通过乐观锁机制,避免数据覆盖更新。
库存超卖
常见的库存扣减方式有:
下单减库存:即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
付款减库存:即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。
预扣库存:这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 30 分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。
至于采用哪一种减库存方式更多是业务层面的考虑,减库存最核心的是大并发请求时保证数据库中的库存字段值不能为负数。
方案一:
通常在扣减库存的场景下使用行级锁,通过数据库引擎本身对记录加锁的控制,保证数据库的更新的安全性,并且通过where语句的条件,保证库存不会被减到0以下,也就是能够有效的控制超卖的场景。
update ... set amount = amount - 1 where id = $id and amount - 1 >=0
方案二:
设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时 SQL 语句会报错。
减少订单快照存储成本
商品信息是可以修改的,当用户下单后,为了更好解决后面可能存在的买卖纠纷,创建订单时会同步保存一份商品详情信息,称之为订单快照。
同一件商品,会有很多用户会购买,如果热销商品,短时间就会有上万的订单。如果每个订单都创建一份快照,存储成本太高。另外商品信息虽然支持修改,但毕竟是一个低频动作。我们可以理解成,大部分订单的商品快照信息都是一样的,除非下单时用户修改过。
如何实时识别修改动作是解决快照成本的关键所在。我们采用摘要比对的方法。创建订单时,先检查商品信息摘要是否已经存在,如果不存在,会创建快照记录。订单明细会关联商品的快照主键。
public class DigestTest {
public static void encodeStr(String data) {
String encodeS = DigestUtils.md5Hex(data);
System.out.println(encodeS);
}
public static void main(String[] args) {
String data = "网销投连险是保险公司的一款保险产品,在互联网金融上还是很常见的。" + "比如京东天天盈,网易有钱零钱++。这些保险削弱了保险的保障功能,降低成本,从而提高保险的理财功能提高理财收益。"
+ "投连险基本和银行结构性理财产品一样,信息披露度不高,但是有保险公司兜底,不至于整个平台跑路。"
+ "投资投连险可以想象为投资一个起点低的银行理财产品吧。网销投连险一般都受益在4-6%,不承诺保本。"
+ "经常爆出保险公司的保障型长期投连险出现投资亏损新闻,但是网销短期投连险投资型投连险目前没有出现亏损,基本也能按照预期收益兑付。"
+ "网销投连险安全性和收益性都比较居中,短期产品危险系数不高,但是在债券违约的大环境下,长期产品安全性没有太大保障。" + "不过好在保险公司没有跑路风险,至少不会把本金损失殆尽啊。";
encodeStr(data);
}
}
由于订单快照属于非核心操作,即使失败也不应该影响用户正常购买流程,所以通常采用异步流程执行。
购物车设计
购物车是电商系统的标配功能,暂存用户想要购买的商品。分为添加商品、列表查看、结算下单三个动作。
技术设计并不是特别复杂,存储的信息也相对有限(用户id、商品id、sku_id、数量、添加时间)。这里特别拿出来单讲主要是用户体验层面要注意几个问题:
添加购物车时,后端校验用户未登录,常规思路,引导用户跳转登录页,待登录成功后,再添加购物车。多了一步操作,给用户一种强迫的感觉,体验会比较差。有没有更好的方式?
如果细心体验京东、淘宝等大平台,你会发现即使未登录态也可以添加购物车,这到底是怎么实现的?
细细琢磨其实原理并不复杂,服务端这边在用户登录态校验时,做了分支路由,当用户未登录时,会创建一个临时token,作为用户的唯一标识,购物车数据挂载在该token下,为了避免购物车数据相互影响以及设计的复杂度,这里会有一个临时购物车表。
当然,临时购物车表的数据量并不会太大,why?用户不会一直闲着添加购物车玩,当用户登录后,查看自己的购物车,服务端会从请求的cookie里查找购物车token标识,并查询临时购物车表是否有数据,然后合并到正式购物车表里。
特别说明:
临时购物车是不是一定要在服务端存储?未必。有架构师倾向前置存储,将数据存储在浏览器或者APP LocalStorage,这部分数据毕竟不是共享的,但是不太好的增加了设计的复杂度。
客户端需要借助本地数据索引,远程请求查完整信息。
如果是登录态,还要增加数据合并逻辑
考虑到这两部分数据只是用户标识的差异性,所以作者还是建议统一存到服务端,日后即使业务逻辑变更,只需要改动一处就可以了,毕竟自运营系统,良好的可维护性也需要我们非常关注的。
附:市场主流电商模式
B2B:企业对企业之间的贸易往来,如:1688
B2C:企业与个人之间的电子商务,如:天猫、京东
C2C:消费者与消费者之间的电子商务,如:淘宝
C2B:消费者与企业之间的电子商务,收集消费者的定制化需求,由工厂统一定制化生产
O2O:线上与线下相结合的电子商务,如:美团、阿里本地生活
A2B2C:代理商、商家、消费者,在传统B2C中增加代理商环节
B2M:商家与服务商、经销商之间的交易模式
M2C:生产商与终端消费者之间的直接交易模式
B2A(B2G):企业与政府部门之间的交易模式
B2B2C:生产商供应商、销售方与消费者之间的交易模式
往期推荐