简单商城Shop
系统总体架构
系统流程图
页面展示
系统工程
工程结构截图
Shop-Parent工程
存放其他工程的工程依赖,是其他工程的负工程,方便对依赖的管理而建立。
maven依赖如下:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.2.RELEASE</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- eureka server -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-eureka-server</artifactId>
</dependency>
<!-- feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-feign</artifactId>
</dependency>
<!-- 集成web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 集成lombok 框架 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- 集成redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 集成mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.46</version>
</dependency>
<!-- freemarker -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!-- 阿里巴巴数据源 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.14</version>
</dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.30</version>
</dependency>
<!-- springboot整合activemq -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-activemq</artifactId>
</dependency>
<!-- 集成发送邮件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies>
Shop-api工程
是SpringCloud远程调用的消费者和提供者的公共接口工程。包含Shop-api-item工程和Shop-api-member工程。
数据库设计
(1)用户表shop_users
CREATE TABLE `shop_user`(
`id` int(20) NOT NUll auto_increment COMMENT'主键(自增长)',
`username` VARCHAR(50) NOT NULL COMMENT'用户名',
`password` VARCHAR(32) NOT NULL COMMENT'密码,加密存储',
`phone` VARCHAR(20) DEFAULT NULL COMMENT'手机号',
`email` VARCHAR(50) DEFAULT NULL COMMENT'邮箱',
`openId` VARCHAR(100) DEFAULT NULL COMMENT'登录Id',
`created` TIMESTAMP not NULL DEFAULT CURRENT_TIMESTAMP COMMENT'自动插入,创建时间',
`updated` TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT'自动插入,修改时间',
PRIMARY KEY(`id`),
UNIQUE KEY `username` (`username`) USING BTREE,
UNIQUE KEY `phone` (`phone`) USING BTREE,
UNIQUE KEY `email` (`email`) USING BTREE
)ENGINE=INNODB COMMENT='用户表';
(2)商品信息表shop-item
CREATE TABLE `shop_item` (
`id` bigint(20) NOT NULL auto_increment COMMENT '商品id,同时也是商品编号',
`title` varchar(100) NOT NULL COMMENT '商品标题',
`sell_point` varchar(500) DEFAULT NULL COMMENT '商品卖点',
`price` bigint(20) NOT NULL COMMENT '商品价格,单位为:分',
`num` int(10) NOT NULL COMMENT '库存数量',
`barcode` varchar(30) DEFAULT NULL COMMENT '商品条形码',
`image` varchar(500) not NULL COMMENT '商品图片',
`desc_id` bigint(10) NOT NULL COMMENT '商品详情Id',
`cid` bigint(10) NOT NULL COMMENT '所属栏目',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '商品状态,1-正常,2-下架,3-删除',
`created` TIMESTAMP not NULL DEFAULT CURRENT_TIMESTAMP COMMENT'自动插入,创建时间',
`updated` TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT'自动插入,修改时间',
PRIMARY KEY (`id`),
KEY `cid` (`cid`),
KEY `status` (`status`),
KEY `updated` (`updated`)
) ENGINE=InnoDB COMMENT='商品表';
(3)商品描述表shop_item_desc
CREATE TABLE `shop_item_desc` (
`id` bigint(20) NOT NULL auto_increment COMMENT '商品ID',
`itemdesc` text COMMENT '商品描述',
`created` TIMESTAMP not NULL DEFAULT CURRENT_TIMESTAMP COMMENT'自动插入,创建时间',
`updated` TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT'自动插入,修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='商品描述表';
(4)商品类目表shop_item_category
CREATE TABLE `shop_item_category` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目ID',
`name` varchar(50) not NULL COMMENT '类目名称',
`img` varchar(150) not NULL COMMENT '图片地址',
`status` int(1) DEFAULT '1' COMMENT '状态。可选值:1(正常),2(删除)',
`created` TIMESTAMP not NULL DEFAULT CURRENT_TIMESTAMP COMMENT'自动插入,创建时间',
`updated` TIMESTAMP not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT'自动插入,修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COMMENT='商品类目';
实体类设计
完全和数据库字段一致。
Shop-common工程
将工程中常用的功能方法和类抽离出来。
Shop-EurekaServer工程
系统的Eureka注册中心工程。
配置文件:
server:
port: 8761
context-path: /
eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
Shop-Item工程
商品工程。
(1)额外添加的maven依赖
<!-- 集成mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.1.1</version>
</dependency>
(2)配置文件
server:
port: 8764
context-path: /item
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
mybatis:
configuration:
# 开启驼峰uName自动映射到u_name
map-underscore-to-camel-case: true
spring:
application:
name: item
redis:
host: localhost
password: 123457
port: 6739
pool:
max-idle: 100
min-idle: 1
max-active: 1000
max-wait: -1
datasource:
name: test
url: jdbc:mysql://localhost:3306/shop
username: root
password: root
# 使用druid数据源
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
filters: stat
maxActive: 20
initialSize: 1
maxWait: 60000
minIdle: 1
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
maxOpenPreparedStatements: 20
Shop-Member工程
用户工程。
(1)额外添加的依赖
<!-- 集成mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.1.1</version>
</dependency>
(2)配置文件
server:
port: 8762
context-path: /member
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
spring:
application:
name: member
redis:
host: localhost
password: 123457
port: 6379
pool:
max-idle: 100
min-idle: 1
max-active: 1000
max-wait: -11
datasource:
name: test
url: jdbc:mysql://localhost:3306/shop
username: root
password: root
# 使用druid数据源
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
filters: stat
maxActive: 20
initialSize: 1
maxWait: 60000
minIdle: 1
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: select 'x'
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
maxOpenPreparedStatements: 20
activemq:
broker-url: tcp://localhost:61616
user: admin
password: admin
message:
queue: message_queue
Shop-Message工程
发送注册邮件工程。
邮件的自定义报文:
{
"header":{
"interfaceType":"接口类型"
},
"content":{
"mail":" ",
"userName":" "
}
}
配置文件:
server:
port: 8763
context-path: /message
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
spring:
application:
name: message
activemq:
broker-url: tcp://localhost:61616
user: admin
password: admin
mail:
host: smtp.163.com
username: xiaoqianittest@163.com
password: DGTQZCESAMGOWPVT
enable: true
smtp:
auth: true
starttls:
enable: true
required: true
Shop-Moblie-Web工程
用户交互工程。
Redis中购物车信息格式:
key: userOpenId
value: { "cart":["itemId1", "itemId2", "itemId3"...] }
(1)额外添加依赖
<!-- https://mvnrepository.com/artifact/com.alipay.sdk/alipay-sdk-java -->
<!-- 整合支付宝网关接口 -->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>3.7.110.ALL</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
<!-- 整合jsp -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
(2)配置文件
server:
# port: 8763
port: 80
context-path: /Shop
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
spring:
freemarker:
suffix: .ftl
templateEncoding: UTF-8
templateLoaderPath: classpath:/templates/
mvc:
view:
prefix: /WEB-INF/
suffix: .jsp
application:
name: shop
redis:
host: localhost
password: 123457
port: 6379
pool:
max-idle: 100
min-idle: 1
max-active: 1000
max-wait: -1
系统介绍
本系统是在线购物支付网站,采用SpringCloud框架进行分布式系统的设计。整个系统中的角色包含注册中心,服务提供者和服务消费者。这里注册中心使用Spring Cloud Eureka,服务消费者是与用户交互的Web工程。服务提供者,共包含3个服务工程:用户服务、商品服务和邮件服务。服务的调用使用Spring Cloud Feign,RPC远程调用的数据通信是Restful风格,使用Json串。系统的数据库采用mySql,ORM框架使用Mybatis。
用户服务包含用户登录、注册和注册邮件通知功能。对应的数据库表包含用户名、邮件、密码等字段,其中用户名、邮件和手机号加了唯一索引。用户注册时,使用MD5算法对密码加密后再存入数据库。入数据库成功,则发送邮件报文到ActiveMQ的消息队列,等待邮件服务监听消息并发送邮件。用户登录在验证用户名和密码后,将随机生成的字符串作为loginToken令牌作为键,将用户的openId作为值存储到Redis中,目的是为了后面的购物车相关操作。
商品服务包含首页展示商品、查询商品详情功能。对应的数据库表分为商品信息表、商品详情表、商品类目表三个表。目的是为了解耦商品的信息,对商品表进行垂直拆分,表之间通过特定字段进行关联。首页展示商品时,将查询到的信息按照类目进行分类,最后封装到Map中传到前端页面。查询商品详情时,根据用户点击前端页面传回的商品Id值进行查询商品详情描述,并返回前端页面即可。
邮件服务包含监听邮件消息和发送邮件两部分。使用JMSListener监听队列消息,有消息时对消息格式进行校验,邮件格式为自定义报文,有特定的报文头。监听和校验完又叫消息后,对报文解析后发送邮件即可。
最后建立一个Web工程,作为服务消费,用来消费上述三个服务,同时提供和用户交互的页面和业务逻辑处理。除调用上述三个服务进行用户登录、注册、商品展示和查询功能外,该工程单独提供了购物车和支付功能。购物车功能以Redis服务器为核心进行存储信息。按照自定义的购物车信息格式,为两层键值对形式,第一层键为用户的openId,第二层键为常量字符串”cart”,值为商品id的json数组。由此可以进行购物车的展示、删除商品、新增商品功能。最后支付功能对接支付宝的支付网关接口。使用支付宝开放平台的沙箱账号和工具以及Demo进行支付功能的整合。
系统的启动,只需启动Eureka、服务提供者和消费者工程即可。
一些开发笔记
我踩过的坑:
1、通过@requestBody可以将请求体中的JSON字符串绑定到相应的bean上,当然,也可以将其分别绑定到对应的字符串上。
2、在feignClient(服务提供者)端,要注意暴露接口的返回值为JSON,可以用@Controller + @ResponsedBody或者@RestController修饰。否则报错feign.FeignException: status 404 reading
3、在html中,href中,不加/包含context-path,如href=”index”,如果加/代表从域名开始拼接,href=”/mobile/index”
4、从前端传过来的参数,若为空,是空字符串””,而不是null。
5、fastjson,注意怎么传list<类>,特别注意JSONARRAY里面的类型好像只能是Integer,注意怎么转化为需要的JAVA基本类型
6、Mybatis的Maven依赖,如果引用了,但是没有配置mysql会在启动报错
7、loadbalance error …client: item,这类错误,可能需要重启一下工程即可
8、从json中提取实体类:ItemEntity item = itemJson.getObject(Constants.HTTP_RES_CODE_DATA, ItemEntity.class)
9、springcloud的api接口的参数需要用@RequestParam
关于用户登录信息验证
我这里的设计为:cookie验证登陆+redis存储用户信息/状态(openid)。用户登录时生成随机令牌token,写cookie(“token”, token),在写redis(token, openid)。用户再次登陆时cookie中携带token,即可验证状态。
如果客户端禁用cookie,这里可以设置session重写。用户登陆后写session(“token”, openid),后将对应的JSSESSIONID写入页面的隐藏域中。下次登陆时页面填入JSSESSIONID发送给客户端,登陆后即可验证登陆状态。
电商系统/秒杀系统常见问题
问题概览
库存预热
提前把商品的库存加载到Redis中去,让整个流程都在Redis里面去做,然后等秒杀结束了,再异步的去修改库存就好了。
用户下单
为了提升下单速度,我们将订单数据存入到redis缓存中,如果用户支付了,再将redis中的订单存到Mysql中,并清空redis中的订单。如果没有库存了,则将缓存中的商品数据同步到Mysql中,并情况redis中该商品的缓存。
创建订单
用户每次下单的时候,我们可以创建一个队列进行排队,然后采用异步的方式创建订单,排队我们可以采用Redis的队列实现。如果符合下单资格,只需要先记录用户下单数据,存入redis队列(先不要创建订单),然后采用异步创建订单的方式,从redis队列中,读取下单信息,创建订单。
下单状态查询
我们需要做一个页面判断,每过1s查询一次下单状态。
防止重复排队
我们用一个保存排队信息的hash键值对。用户每次抢单的时候,一旦排队,我们设置一个自增值,让该值的初始值为1,只有当该值为1的时候才能进行后续下单操作。如果值>1,则表明已经排队,不允许重复排队,如果重复排队,则对外抛出异常,并抛出异常信息100表示已经正在排队。
超卖问题
判断商品数量的时候,不要用传统的读取操作,而是用redis的增量命令,才能确保数据的精准性。
订单支付
下单成功后会跳转到支付页面,支付页面会根据用户名查看用户订单,创建预支付信息,发送请求到支付中心,获取二维码。支付状态通过回调地址发送给MQ,我们需要侦听这个MQ,如果支付成功,则修改订单状态,将订单信息写入mysql,清除redis中的订单信息和排队信息等。如果支付失败,则删除订单,回滚库存。
超时支付订单库存回滚
如果一段时间未支付,则需要回滚库存,删除订单操作。采用rabbitMQ的延时消息队列来实现。
Rabbitmq实现延时队列一般而言有两种形式:- 第一种方式:利用两个特性: Time To Live(TTL)、Dead Letter Exchanges(DLX)[A队列过期->转发给B队列] - 第二种方式:利用rabbitmq中的插件x-delay-message TTL RabbitMQ可以针对队列设置x-expires(则队列中所有的消息都有相同的过期时间)或者针对Message设置x-message-ttl(对消息进行单独设置,每条消息TTL可以不同),来控制消息的生存时间,如果超时(两者同时设置以最先到期的时间为准),则消息变为dead letter(死信) Dead Letter Exchanges(DLX) RabbitMQ的Queue可以配置x-dead-letter-exchange和x-dead-letter-routing-key(可选)两个参数,如果队列内出现了dead letter,则按照这两个参数重新路由转发到指定的队列。 x-dead-letter-exchange:出现dead letter之后将dead letter重新发送到指定exchange x-dead-letter-routing-key:出现dead letter之后将dead letter重新按照指定的routing-key发送
超卖问题
总结:
- SQL层面:where条件
- 服务端代码层面:乐观锁
- 架构层面:队列 | Redis减库存
-
如果扣减多个库存num时
update coupon set stock=stock - #{num} where id = #{couponId} and stock >= #{num}
解释场景:
使用业务自身的条件做为乐观锁,但是存在ABA问题,对比其他方案的好处是不用增加version版本字段。
id是主键索引的前提下,采用上面的方式,只做数据安全校验,可以有效减库存,性能更高,避免大量无用sql,只要有库存就也可以操作成功.
-
为了解决上面的超卖问题,我们当然可以在Service层给更新表添加一个事务,这样每个线程更新请求的时候都会先去锁表的这一行(悲观锁),更新完库存后再释放锁。可这样就太慢了,1000个线程可等不及。
所以我们需要乐观锁。一个最简单的办法就是,给每个商品库存一个版本号version字段
CREATE TABLE `stock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称', `count` int(11) NOT NULL COMMENT '库存', `sale` int(11) NOT NULL COMMENT '已售', `version` int(11) NOT NULL COMMENT '乐观锁,版本号', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
我们在实际减库存的SQL操作中,首先判断version是否是我们查询库存时候的version,如果是,扣减库存,成功抢购。如果发现version变了,则不更新数据库,返回抢购失败。
<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock"> update stock <set> sale = sale + 1, version = version + 1, </set> WHERE id = #{id,jdbcType=INTEGER} AND version = #{version,jdbcType=INTEGER} </update>
-
引入队列,然后将所有写DB操作在单队列中排队,完全串行处理。当达到库存阀值的时候就不在消费队列,并关闭购买功能。这就解决了超卖问题。
优点:解决超卖问题,略微提升性能。
缺点:性能受限于队列处理机处理性能和DB的写入性能中最短的那个,另外多商品同时抢购的时候需要准备多条队列。将写操作前移到Memcached中,同时利用Memcached的轻量级的锁机制CAS来实现减库存操作。
优点:读写在内存中,操作性能快,引入轻量级锁之后可以保证同一时刻只有一个写入成功,解决减库存问题。
缺点:没有实测,基于CAS的特性不知道高并发下是否会出现大量更新失败?不过加锁之后肯定对并发性能会有影响。将提交操作变成两段式,先申请后确认。然后利用Redis的原子自增操作(相比较MySQL的自增来说没有空洞),同时利用Redis的事务特性来发号,保证拿到小于等于库存阀值的号的人都可以成功提交订单。然后数据异步更新到DB中。
优点:解决超卖问题,库存读写都在内存中,故同时解决性能问题。
缺点:由于异步写入DB,可能存在数据不一致。另可能存在少买,也就是如果拿到号的人不真正下订单,可能库存减为0,但是订单数并没有达到库存阀值。
常见减库存的方式
电商场景下的购买过程一般分为两步:下单和付款。“提交订单”即为下单,“支付订单”即为付款。基于此设定,减库存一般有以下几个方式:
- 下单减库存。买家下单后,扣减商品库存。下单减库存是最简单的减库存方式,也是控制最为精确的一种
- 付款减库存。买家下单后,并不立即扣减库存,而是等到付款后才真正扣减库存。但因为付款时才减库存,如果并发比较高,可能出现买家下单后付不了款的情况,因为商品已经被其他人买走了
- 预扣库存。这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 15 分钟),超过这段时间,库存自动释放,释放后其他买家可以购买
能够看到,减库存方式是基于购物过程的多阶段进行划分的,但无论是在下单阶段还是付款阶段,都会存在一些问题。
预扣库存是目前比较流行的方式。
支付的幂等性
定义
前端重复提交选中的数据,后台应该只产生对应本次提交的一个响应结果。
用户发起一笔付款请求,应该只扣除用户账号一次钱,即使遇到网络重发或系统bug重发时,也只扣除一次钱。
创建业务订单时,一次业务请求只能创建一个订单
实现幂等
需要有唯一标识来标记请求,比如订单号,token等。
先查询一下订单是否已经支付过;
如果已经支付过,则返回支付成功;如果没有支付,进行支付流程,修改订单状态为‘已支付’。 保证幂等性就需要查询和变更状态操作加锁,将并行操作改为串行操作。
懒删除
实战项目中对记录的删除,是调整某字段值,比如1为显示,-1为不显示。