电商系统是如何设计的?

在需求还不太明确的情况下,比较可行的方式就是,先把那些不太会变化的核心系统搭建出
来,尽量简单地实现出一个最小化的系统,然后再逐步迭代完善。
电商系统的核心流程是什么样的?
接下来我带你一起来设计这个电商的核心系统。

程序员要学会需求分析,因为往往需求来了就一两句话,要学会自己分析抓住核心,这样就不会随着需求变动,而被动的感到焦虑。
不要一上来就设计功能,而是先要回答下面这两个问题:
1. 这个系统(或者是功能)是给哪些人用的?
2. 这些人使用这个系统来解决什么问题?

第一个问题,电商系统给哪些人用?
首先得有买东西的人,我们叫“用户”,还得有卖东西的人?我们叫“运营人员”。还有什
么人会用这个系统?老板啊!你记住,你在设计任何一个系统的时候,千万不要把老板或者
是领导给忘了,他们是给你钱的人,他们的意见非常重要!
然后我们一起回答第二个问题:用户、运营和老板,分别用电商系统来干什么?
这个问题也很容易回答,用户用系统来买东西,运营用系统来卖东西,老板需要在系统中看
到他赚了多少钱。这两个问题的答案,或者说是业务需求,稍加细化后,可以用下面这个图
来清晰的表述:

image.png

购物流程图
image.png

将业务流程再细化变化为时序图

image.png

  1. 用户开始浏览商品,需要有一个商品模块来支撑,给用户展示商品的介绍、价格等等这
    些信息。
    2. 用户把选好的商品加入购物车,这个步骤,也需要一个购物车模块来维护用户购物车中
    的商品。
    3. 用户下单肯定需要一个订单模块来创建这个新订单。订单创建好了之后,需要把订单中
    的商品从购物车中删除掉。
    4. 订单创建完成后,需要引导用户付款,也就是发起支付流程,这里需要有一个支付模块
    来实现支付功能,用户成功完成支付之后,需要把订单的状态变更为“已支付”。
    5. 之后运营人员就可以发货了,在系统中,发货这个步骤,需要扣减对应商品的库存数
    量,这个功能需要库存模块来实现,发货完成后,还需要把订单状态变更为“已发
    货”。
    6. 最后,用户收货之后,在系统中确认收货,系统把订单状态变更为“已收货”,流程结
    束。

电商系统的功能模模块划分

image.png

上面这个图,我使用的是 UML 中的包图 (Package Diagram) 来表示。整个系统按照功
能,划分为十个模块,除了购物流程中涉及到的:商品、订单、购物车、支付、库存五个模
块以外,还补充了促销、用户、账户、搜索推荐和报表这几个模块,这些都是构建一个电商
系统必不可少的功能。我们一个一个来说每个模块需要实现的功能。

1.商品:维护和展示商品信息和价格。
2.订单:维护订单信息和订单状态,计算订单金额。
3.购物车:维护用户购物车中的商品。
4.支付:负责与系统内外部的支付渠道对接,实现支付功能。
5.库存:维护商品的库存数量和库存信息。
6.促销:制定促销规则,计算促销优惠。
7.用户:维护系统的用户信息,注意用户模块它是一个业务模块,一般不负责用户登录和
8.认证,这是两个完全不同的功能。
9.账户:负责维护用户的账户余额。
10.搜索推荐:负责商城中,搜索商品和各种列表页和促销页的组织和展示,简单的说就是
决定让用户优先看到哪些商品。报表:实现统计和分析功能,生成报表,给老板来做经营分析和决策使用。

这里面需要特别说一下促销模块,它是电商系统中,最复杂的一个模块。各种优惠券、满
减、返现等等这些促销规则,每个都非常复杂,再加上这些规则叠加计算,常常是复杂到连
制定促销规则的人都搞不清楚。
所以每个电商公司无一例外都爆出过,因为促销规则制定失误,而产生非常便宜的“羊毛
单”,让精明的消费者薅了“羊毛”。不过五花八门的促销是提升销售最有效的手段,肯定
不能因噎废食。
作为系统设计者,我们需要把促销的变化和复杂性封禁在促销模块内部,不能让一个促销模
块把整个电商系统都搞得非常复杂,否则就很难去设计和实现。
可行的做法是,把促销模块与其他模块的接口设计的相对简单和固定,这样系统的其他模块
就不会因为新的促销玩儿法而改变。
在创建订单时,订单模块把商品和价格信息传给促销模块,促销模块返回一个可以使用的促
销列表,用户选择好促销和优惠,订单模块把商品、价格、促销优惠这些信息,再次传给促
销模块,促销模块则返回促销价格。
最终生成的订单中,只记录订单使用了哪几种促销,以及最终的促销价就可以了。这样,不
管促销这个模块的玩儿法怎么变化,订单和其他模块的业务逻辑不需要随之改变。

创建和更新订单时,如何保证数据准确无误?

image.png

image.png

理解了这几种隔离级别,最后我们给出一种兼顾并发、性能和数据一致性的交易实现。这个
实现在隔离级别为 RC 和 RR 时,都是安全的。
1. 我们给账户余额表增加一个 log_id 属性,记录最后一笔交易的流水号。
2. 首先开启事务,查询并记录当前账户的余额和最后一笔交易的流水号。
3. 然后写入流水记录。4. 再更新账户余额,需要在更新语句的 WHERE 条件中限定,只有流水号等于之前查询出
的流水号时才更新。
5. 然后检查更新余额的返回值,如果更新成功就提交事务,否则回滚事务。
需要特别注意的一点是,更新账户余额后,不能只检查更新语句是不是执行成功了,还需要
检查返回值中变更的行数是不是等于 1。因为即使流水号不相等,余额没有更新,这条更新
语句的执行结果仍然是成功的,只是更新了 0 条记录。

  1. 1 mysql> begin;
  2. 2 Query OK, 0 rows affected (0.00 sec)
  3. 3
  4. 4 mysql>
  5. -- 查询当前账户的余额和最后一笔交易的流水号。
  6. 5 mysql> select balance, log_id from account_balance where user_id = 0;
  7. 6 +---------+--------+
  8. 7 | balance | log_id |
  9. 8 +---------+--------+
  10. 9 |
  11. 100 |
  12. 3 |
  13. 10 +---------+--------+
  14. 11 1 row in set (0.00 sec)
  15. 12
  16. 13 mysql>
  17. -- 插入流水记录。
  18. 14 mysql> insert into account_log values
  19. 15
  20. -> (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
  21. 16 Query OK, 1 row affected (0.01 sec)
  22. 17
  23. 18 mysql>
  24. -- 更新余额,注意where条件中,限定了只有流水号等于之前查询出的流水号3时才更新。
  25. 19 mysql> update account_balance
  26. 20 -> set balance = balance + 100, log_id = LAST_INSERT_ID(), timestamp = NOW
  27. 21 -> where user_id = 0 and log_id = 3;
  28. 22 Query OK, 1 row affected (0.00 sec)
  29. 23 Rows matched: 1
  30. Changed: 1
  31. Warnings: 0
  32. 24
  33. 25 mysql>
  34. -- 这里需要检查更新结果,只有更新余额成功(Changed: 1)才提交事务,否则回滚事务。
  35. 26 mysql> commit;
  36. 27 Query OK, 0 rows affected (0.01 sec)

账户系统用于记录每个用户的余额,为了保证数据的可追溯性,还需要记录账户流水。流水
记录只能新增,任何情况下都不允许修改和删除,每次交易的时候需要把流水和余额放在同
一个事务中一起更新。
事务具备原子性、一致性、隔离性和持久性四种基本特性,也就是 ACID,它可以保证在一
个事务中执行的数据更新,要么都成功,要么都失败。并且在事务执行过程中,中间状态的
数据对其他事务是不可见的。
ACID 是一种理想情况,特别是要完美地实现 CI,会导致数据库性能严重下降,所以
MySQL 提供的四种可选的隔离级别,牺牲一定的隔离性和一致性,用于换取高性能。这四
种隔离级别中,只有 RC 和 RR 这两种隔离级别是常用的,它们的唯一区别是在进行的事务
中,其他事务对数据的更新是否可见。

我们在讲解数据库事务的时候,讲的内容是如何用事务解决交易的问题,而没讲
MySQL 是如何实现 ACID 的。因为数据库已经把事务封装的非常好了,我们只需要掌握如
何使用就可以很好地解决问题。
但分布式事务不是这样的,我刚刚说了,并没有一种分布式事务的服务或者组件,能帮我们
很简单地就解决分布式系统下的数据一致性问题。我们在使用分布式事务时,更多的情况
是,用分布式事务的理论来指导设计和开发,自行来解决数据一致性问题。也就是说,要解
决分布式一致性问题,你必须掌握几种分布式事务的实现原理。

分布式事务的解决方案有很多,比如:2PC、3PC、TCC、Saga 和本地消息表等等。这些
方法,它的强项和弱项都不一样,适用的场景也不一样,所以最好这些分布式事务你都能够
掌握,这样才能在面临实际问题的时候选择合适的方法。这里面,2PC 和本地消息表这两
种分布式事务的解决方案,比较贴近于我们日常开发的业务系统。

这节课我们讲解了,如何用分布式事务的几种方法来解决分布式系统中的数据一致性问题。
对于订单和优惠券这种需要强一致的分布式事务场景,可以采用 2PC 的方法来解决问题。
2PC 它的优点是强一致,但是性能和可用性上都有一些缺陷。本地消息表适用性更加广
泛,虽然在数据一致性上有所牺牲,只能满足最终一致性,但是有更好的性能,实现简单,
系统的稳定性也很好,是一种非常实用的分布式事务的解决方案。
无论是哪种分布式事务方法,其实都是把一个分布式事务,拆分成多个本地事务。本地事务
可以用数据库事务来解决,那分布式事务就专注于解决如何让这些本地事务保持一致的问
题。我们在遇到分布式一致性问题的时候,也要基于这个思想来考虑问题,再结合实际的情
况选择分布式事务的方法。

  1. curl -X POST "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d '{ "analyzer": "ik_smart", "text": "极客时间" }'
  2. {
  3. "tokens" : [
  4. {
  5. "token" : "极",
  6. "start_offset" : 0,
  7. "end_offset" : 1,
  8. "type" : "CN_CHAR",
  9. "position" : 0
  10. },
  11. {
  12. "token" : "客",
  13. "start_offset" : 1,
  14. "end_offset" : 2,
  15. "type" : "CN_CHAR",
  16. "position" : 1
  17. },
  18. {
  19. "token" : "时间",
  20. "start_offset" : 2,
  21. "end_offset" : 4,
  22. "type" : "CN_WORD",
  23. "position" : 2
  24. }
  25. ]
  26. }
  27. curl -X PUT "localhost:9200/sku" -H 'Content-Type: application/json' -d '{
  28. "mappings": {
  29. "properties": {
  30. "sku_id": {
  31. "type": "long"
  32. },
  33. "title": {
  34. "type": "text",
  35. "analyzer": "ik_max_word",
  36. "search_analyzer": "ik_max_word"
  37. }
  38. }
  39. }
  40. }'
  41. {"acknowledged":true,"shards_acknowledged":true,"index":"sku"}
  42. curl -X POST "localhost:9200/sku/_doc/" -H 'Content-Type: application/json' -d '{
  43. "sku_id": 100002860826,
  44. "title": "烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果"
  45. }'
  46. {"_index":"sku","_type":"_doc","_id":"yxQVSHABiy2kuAJG8ilW","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}
  47. curl -X POST "localhost:9200/sku/_doc/" -H 'Content-Type: application/json' -d '{
  48. "sku_id": 100000177760,
  49. "title": "苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待"
  50. }'
  51. {"_index":"sku","_type":"_doc","_id":"zBQWSHABiy2kuAJGgim1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1}
  52. curl -X GET 'localhost:9200/sku/_search?pretty' -H 'Content-Type: application/json' -d '{
  53. "query" : { "match" : { "title" : "苹果手机" }}
  54. }'
  55. {
  56. "took" : 23,
  57. "timed_out" : false,
  58. "_shards" : {
  59. "total" : 1,
  60. "successful" : 1,
  61. "skipped" : 0,
  62. "failed" : 0
  63. },
  64. "hits" : {
  65. "total" : {
  66. "value" : 2,
  67. "relation" : "eq"
  68. },
  69. "max_score" : 0.8594865,
  70. "hits" : [
  71. {
  72. "_index" : "sku",
  73. "_type" : "_doc",
  74. "_id" : "zBQWSHABiy2kuAJGgim1",
  75. "_score" : 0.8594865,
  76. "_source" : {
  77. "sku_id" : 100000177760,
  78. "title" : "苹果 Apple iPhone XS Max (A2104) 256GB 金色 移动联通电信4G手机 双卡双待"
  79. }
  80. },
  81. {
  82. "_index" : "sku",
  83. "_type" : "_doc",
  84. "_id" : "yxQVSHABiy2kuAJG8ilW",
  85. "_score" : 0.18577608,
  86. "_source" : {
  87. "sku_id" : 100002860826,
  88. "title" : "烟台红富士苹果 5kg 一级铂金大果 单果230g以上 新鲜水果"
  89. }
  90. }
  91. ]
  92. }
  93. }