主要业务包括用户的注册、登录;商品的创建、商品详细信息的展示、单个商品的详情与下单。采用前后端分离的设计思想,使用Ajax进行交互
后端:SpringBoot + Mybatis + Mysql + Redis + Nginx + RocketMQ
前端:Html + CSS + JS
部署在阿里云服务器上面。
项目源码地址:Gitee

MySQL数据库设计

  • 主键查询:千万级别数据 = 1-10 毫秒
  • 唯一索引查询:千万级别数据 = 10-100 毫秒
  • 非唯一索引查询:千万级别数据 = 100-1000 毫秒
  • 无索引:百万条数据 = 1000 毫秒+

表结构

  • 用户信息表:user_info(id、name、gender、age、telephone)
  • 用户密码表:uesr_password(id、encrypte_password、user_id)
  • 商品信息表:item(id、title、price、description、sales、img_url)
  • 商品库存表:item_stock(id、stock、item_id)
  • 活动商品信息表:promo(id、promo_name、start_time、end_time、item_id、promo_item_price)
  • 订单信息表:order_info(id、user_id、item_id、promo_id、item_price、amount、order_price)
  • 序列号信息表:sequence_info(name、current_value、step)
  • 库存流水表:stock_log_id(stock_log_id、item_id、amount、status)

💡 用户信息表(user_info)存放用户的个人信息,用户密码表(uesr_password)存放用户加密后的密码。密码和用户的主表信息分开存储,一是方便管理、二是一般企业级项目中密码可能会由第三方管理平台托管。
💡 库存流水表(stock_log_id)用于在 RocketMQ 调用 checkLocalTransaction() 方法时判断消息的下一步操作(回滚还是提交)

系统设计

系统设计遵从领域模型的分层设计思想

  • 接入层(View Object):与前端对接的模型,隐藏内部实现,供展示的聚合模型。
    View Object:返回给前端的业务模型,保证了 UI 只使用到需要展示的字段
  • 业务层(Domain Model):领域模型,业务核心模型,拥有生命周期,贫血并以服务输出能力。
    Domain Model:核心领域模型。在 Service 层组装了核心的领域模型,真正意义上处理业务逻辑的模型。
  • 数据层(Data Object):数据模型,同数据库映射,用以 ORM 方式操作数据库的能力模型。
    Data Object:与数据库字段一一映射。负责数据存储到 Service 层的数据传输

用户模块

  1. otp 短信获取
  2. otp 用户注册
  3. 用户登录(建立 token 和用户登录态之间的联系,存入 Redis 中,实现分布式会话的管理)

商品模块

  1. 商品创建
  2. 商品列表页展示
  3. 商品详情页展示(多级缓存的实现:先从本地缓存中查找商品详情数据,未命中本地缓存,再从 Redis 中查询数据,未命中 Redis,就查询数据库返回数据,并同步到 Redis 和本地缓存中)
  4. 发布活动商品(该接口用于上架活动商品,并将商品库存同步到 Redis 中,同时设置秒杀大闸的限制数字(秒杀令牌的发放数量)

订单模块

  1. 生成验证码(将验证码存储到 Redis 中,用于和用户输入的验证码进行校验)
  2. 生成秒杀令牌(用户下单之前会调用此接口,获取秒杀令牌,拥有秒杀令牌的用户才能下单,秒杀令牌存放在 Redis 中)
  3. 下单(RateLimiter 进行限流,校验用户登录信息,校验秒杀令牌信息。线程池队列泄洪,用来控制流量的大量涌入。初始化订单流水,之后就是真正的下单逻辑,使用 RocketMQ 事务型消息机制)

⭐️ 下单逻辑

  • 用户点击商品详情页面的下单按钮,请求下单接口.在下单接口中会调用 mqProducer 的事务型同步扣减库存的方法,在该方法中 RocketMQ 的 producer 发送了一条 message 到 broker 中,此时这条消息在 broker 中是 prepared 状态(还不能被 comsumr 消费),然后回调 TransactionListener 接口中我们重写的 executeLocalTransaction() 方法,在这个方法中我们进行真正的下单操作(减 Redis 中的库存、订单入库、增加商品销量、设置库存流水状态为成功)。如果下单过程中长时间不返回,RocketMQ 就会自动调用 TransactionListener 接口中另外一个我们重写的 checkLocalTransaction() 方法,在这个方法中我们就可以根据库存流水状态来判断下单操作是否执行成功, 确定 broker 中的 message 是回滚还是提交。如果下单过程中出现异常,我们就会捕获到异常,同时设置订单流水状态为回滚状态,返回 ROLLBACK_MESSAGE,代表下单失败。没有异常就代表下单成功,返回 COMMIT_MESSAGE,这时 broker 中的 message 就会被 consumer 发现,调用 consumer 中的方法,进行数据库中同步扣减库存的操作。
  • 这一套下单逻辑就能确保如果我下单失败,数据库同步库存扣减的消息也会发送失败。如果下单成功,我的消息也会发送成功,数据库库存就会扣减成功。始终与Redis中的库存保持一致

修改内嵌 Tomcat 默认配置

spring-configuration.metedata.json文件中查看 Tomcat 的默认配置。可以在 application.yaml 文件中修改 Tomcat 配置。

  • server.tomcat.accept-count:等待队列长度,默认为 100
"name": "server.tomcat.accept-count",
"type": "java.lang.Integer",
"description": "Maximum queue length for incoming connection requests when all possible request processing threads are in use.",
"sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat",
"defaultValue": 100
  • server.tomcat.max-connections:最大可被连接数,默认为 8192
"name": "server.tomcat.max-connections",
"type": "java.lang.Integer",
"description": "Maximum number of connections that the server accepts and processes at any given time. Once the limit has been reached, the operating system may still accept connections based on the \"acceptCount\" property.",
"sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat",
"defaultValue": 8192
  • server.tomcat.threads.max:最大工作线程数,默认为 200。(经验配置:4 核 8g 的机器 800 个线程)
"name": "server.tomcat.threads.max",
"type": "java.lang.Integer",
"description": "Maximum amount of worker threads.",
"sourceType":"org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat$Threads",
"defaultValue": 200
  • server.tomcat.threads.min-spare:最小工作线程数,默认为 10
"name": "server.tomcat.threads.min-spare",
"type": "java.lang.Integer",
"description": "Minimum amount of worker threads.",
"sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat$Threads",
"defaultValue": 10
  • 默认配置下,连接数超过 8192(最大可被连接数)后会出现拒接连接情况
  • 默认配置下,触发的请求超过 200(最大工作线程数)+ 100(等待队列长度)后拒绝处理

RocketMQ

默认内存大小设置

下载安装后,RocketMQ 默认内存设置比较大。如果服务器内存比较大,可以不调整;如果服务器内存比较小,需要修改默认内存大小。总共有三个配置文件,位于 bin 目录下的 runserver.sh、runbroker.sh、tools.sh 。不调整这三个文件,会导致服务无法启动。

# vim bin/runserver.sh  
JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn512m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m"

# vim bin/runbroker.sh
JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn128m"

# vim bin/tools.sh
JAVA_OPT="${JAVA_OPT} -server -Xms256m -Xmx256m -Xmn256m -XX:PermSize=128m -XX:MaxPermSize=128m"

Start Name Server(摘自官网)

> nohup sh bin/mqnamesrv &
 > tail -f ~/logs/rocketmqlogs/namesrv.log
 The Name Server boot success...

Start Broker(摘自官网)

> nohup sh bin/mqbroker -n localhost:9876 &
 > tail -f ~/logs/rocketmqlogs/broker.log 
 The broker[%s, 172.30.30.233:10911] boot success...

Send & Receive Messages(摘自官网)

Before sending/receiving messages, we need to tell clients the location of name servers. RocketMQ provides multiple ways to achieve this. For simplicity, we use environment variable NAMESRV_ADDR(在发送或者接收消息之前,我们需要告诉客户端 name servers 的位置,RocketMQ 提供了多种实现方式。例如,我们使用环境变量NAMESRV_ADDR)

> export NAMESRV_ADDR=localhost:9876
 > sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer
 SendResult [sendStatus=SEND_OK, msgId= ...

 > sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer
 ConsumeMessageThread_%d Receive New Messages: [MessageExt...

Shutdown Servers(摘自官网)

> sh bin/mqshutdown broker
The mqbroker(36695) is running...
Send shutdown request to mqbroker(36695) OK

> sh bin/mqshutdown namesrv
The mqnamesrv(36664) is running...
Send shutdown request to mqnamesrv(36664) OK

RocketMQ创建Topic

创建 Topic 命令:./mqadmin updateTopic -n localhost:9876 -t stock -c DefaultCluster

  • updateTopic:创建 Topic
  • -n:指定 nameserver 的地址
  • -t:指定 Topic 的名字

问题汇总

前后端跨域请求

后端支持跨域请求:在 Controller 上面加上 @CrossOrigin() 注解

@CrossOrigin(allowCredentials = "true",originPatterns = "*")
//	@CrossOrigin()注解:支持跨域请求
//	allowCredentials参数需配合前端设置xhrFields授信后使得跨域session共享
//	originPatterns参数允许跨域传输所有的header参数,将用于使用token放入header域做session共享的跨域请求(跨域请求的session共享)

在对应接口的 @RequestMapping() 注解中添加参数 consumes = {“application/x-www-form-urlencoded”}

@RequestMapping(value = "/register",method = {RequestMethod.POST},consumes = {"application/x-www-form-urlencoded"})

前端页面 Ajax 请求中添加下面的请求头,以支持跨域请求,与后端呼应。

contentType: "application/x-www-form-urlencoded",
xhrFields:{withCredentials:true},

超卖问题

秒杀场景下,并发会特别的大,有两种情况会导致库存卖超:

  1. 一个用户同时发出了多个请求,如果库存足够,没加限制,用户就可以下多个订单。
  2. 减库存的 SQL 上没有加库存数量的判断,并发的时候也会导致把库存减成负数。

对于第一种情况,可以通过在页面上添加验证码的方式,防止合法用户快速点鼠标同时发出多个请求,并且在数据库的 order_info 表中,对 user_id 和 item_id 加唯一索引,确保就算是刷接口一个用户对一个商品也绝对不会生成两个订单。对于第二种情况需要在扣减库存的 SQL 语句上加上库存数量的判断,只有扣减库存成功才可以生成订单

总结如何解决超卖问题

  • 在sql语句上面加上判断防止数据变为负数
  • 数据库加上唯一索引防止用户重复购买
  • Redis 预减库存以减少数据库访问,库存标记减少 Redis 访问,请求先入队列,异步下单,增强用户体验

RocketMQ 第一次创建 Topic

第一次创建topic时报错如下:

org.apache.rocketmq.tools.command.SubCommandException: UpdateTopicSubCommand command failed site:blog.csdn.net

错误原因:jar包引用失败
解决办法:在tool.sh中${JAVA_HOME}/jre/lib/ext后加上ext文件夹的绝对路径(jdk路径)

vim /app/rocketmq/bin/tools.sh
如JAVA_OPT="${JAVA_OPT} -Djava.ext.dirs=${BASE_DIR}/lib:${JAVA_HOME}/jre/lib/ext:${JAVA_HOME}/lib/ext:/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.252.b09-2.el7_8.x86_64/jre/lib/ext"