秒杀系统设计
概要设计原理
前端:实现动静分离,将静态资源部署到第三方的服务器上实现加速例如 七牛云等CND加速。
后端:先生成要抢购商品的数量的令牌,封装List保存到redis中。之后谁抢到令牌了,就将商品的一些信息保存到MQ中之后异步的根据MQ去修改库存实现秒杀。
Java实现微服务秒杀抢购课程安排
1.秒杀抢购前端优化方案
2.秒杀抢购如何防止超卖问题
3.基于MQ和Redis实现秒杀抢购
4.秒杀抢购如何防止伪造
使用Nginx实现页面缓存(前端优化)
events {
#的最大连接数(包含所有连接数)1024
worker_connections 1024; ## Default: 1024
}
http{
# 代理缓存配置
proxy_cache_path "./meite_cachedata" levels=1:2 keys_zone=meitecache:256m inactive=1d max_size=1000g;
server {
listen 80;
location /{
#使用缓存名称
proxy_cache meitecache;
#对以下状态码实现缓存
proxy_cache_valid 200 206 304 301 302 1d;
#缓存的key
proxy_cache_key $request_uri;
add_header X-Cache-Status $upstream_cache_status;
#反向代理地址
proxy_pass http://127.0.0.1:8080;
}
}
}
使用数据库乐观锁实现防止超卖问题
数据库表接口
CREATE TABLE `meite_order` (
`seckill_id` bigint(20) NOT NULL COMMENT '秒杀商品id',
`user_phone` bigint(20) NOT NULL COMMENT '用户手机号',
`state` tinyint(4) NOT NULL DEFAULT '-1' COMMENT '状态标示:-1:无效 0:成功 1:已付款 2:已发货',
`create_time` datetime NOT NULL COMMENT '创建时间',
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒杀成功明细表';
CREATE TABLE `meite_seckill` (
`seckill_id` bigint(20) NOT NULL COMMENT '商品库存id',
`name` varchar(120) CHARACTER SET utf8 NOT NULL COMMENT '商品名称',
`inventory` int(11) NOT NULL COMMENT '库存数量',
`start_time` datetime NOT NULL COMMENT '秒杀开启时间',
`end_time` datetime NOT NULL COMMENT '秒杀结束时间',
`create_time` datetime NOT NULL COMMENT '创建时间',
`version` bigint(20) NOT NULL DEFAULT '0',
PRIMARY KEY (`seckill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀库存表';
定义秒杀服务接口
/**
* 用户秒杀接口
*
* @phone 手机号码<br>
* @seckillId 库存id
* @return
*/
@RequestMapping("/spike")
public BaseResponse<JSONObject> spike(String phone, Long seckillId);
@RestController
@Slf4j
public class SpikeCommodityServiceImpl extends BaseApiService<JSONObject> implements SpikeCommodityService {
@Autowired
private SeckillMapper seckillMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private RedisUtil redisUtil;
@Override
@Transactional
public BaseResponse<JSONObject> spike(String phone, Long seckillId) {
// 1.验证参数
if (StringUtils.isEmpty(phone)) {
return setResultError("手机号码不能为空!");
}
if (seckillId == null) {
return setResultError("库存id不能为空!");
}
// >>>限制用户访问频率 比如10秒中只能访问一次
Boolean resultNx = redisUtil.setNx(phone, seckillId + "", 10l);
if (!resultNx) {
return setResultError("该用户操作过于频繁,请稍后重试!");
}
// 2.根据库存id查询商品是否存在
SeckillEntity seckillEntity = seckillMapper.findBySeckillId(seckillId);
if (seckillEntity == null) {
return setResultError("该商品信息不存在!");
}
// 3.对库存的数量实现减去1
Long version = seckillEntity.getVersion();
int inventoryDeduction = seckillMapper.optimisticVersionSeckill(seckillId, version);
if (!toDaoResult(inventoryDeduction)) {
log.info(">>>>phone:{},seckillId:{},秒杀失败!", phone, seckillId);
return setResultError("系统错误!");
}
log.info(">>>>phone:{},seckillId:{},扣库存成功!", phone, seckillId);
// 4.添加秒杀成功订单
OrderEntity orderEntity = new OrderEntity();
orderEntity.setSeckillId(seckillId);
orderEntity.setUserPhone(phone);
int insertOrder = orderMapper.insertOrder(orderEntity);
if (!toDaoResult(insertOrder)) {
return setResultError("系统错误!");
}
log.info(">>>>phone:{},seckillId:{},秒杀成功", phone, seckillId);
return setResultSuccess("恭喜你,秒杀成功!");
}
}
数据库访问层
修改库存
public interface SeckillMapper {
/**
* 使用乐观锁修改库存信息 and inventory>0方式
*
* @param seckillId
* @return
*/
@Update("update meite_seckill set inventory=inventory-1 where seckill_id='10001' and inventory>0")
int optimisticLockSeckill(Long seckillId);
/**
* 基于版本号形式实现乐观锁
*
* @param seckillId
* @return
*/
@Update("update meite_seckill set inventory=inventory-1 ,version=version+1 where seckill_id=#{seckillId} and version=#{version} and inventory>0;")
int optimisticVersionSeckill(@Param("seckillId") Long seckillId, @Param("version") Long version);
@Update("update meite_seckill set inventory=inventory-1 where seckill_id='10001';")
int inventoryDeduction(Long seckillId);
@Select("SELECT seckill_id AS seckillId,name as name,inventory as inventory,start_time as startTime,end_time as endTime,create_time as createTime,version as version from meite_seckill where seckill_id=#{seckillId}")
SeckillEntity findBySeckillId(Long seckillId);
}
秒杀成功记录
public interface OrderMapper {
@Insert("INSERT INTO `meite_order` VALUES (#{seckillId},#{userPhone}, '1', now());")
int insertOrder(OrderEntity orderEntity);
}
秒杀服务基于库存令牌桶实现修改商品库存
生产者
MQ相关配置
@Component
public class RabbitmqConfig {
// 添加修改库存队列
public static final String MODIFY_INVENTORY_QUEUE = "modify_inventory_queue";
// 交换机名称
private static final String MODIFY_EXCHANGE_NAME = "modify_exchange_name";
// 1.添加交换机队列
@Bean
public Queue directModifyInventoryQueue() {
return new Queue(MODIFY_INVENTORY_QUEUE);
}
// 2.定义交换机
@Bean
DirectExchange directModifyExchange() {
return new DirectExchange(MODIFY_EXCHANGE_NAME);
}
// 3.修改库存队列绑定交换机
@Bean
Binding bindingExchangeintegralDicQueue() {
return BindingBuilder.bind(directModifyInventoryQueue()).to(directModifyExchange()).with("modifyRoutingKey");
}
}
生产者发送消息
@Component
@Slf4j
public class SpikeCommodityProducer implements RabbitTemplate.ConfirmCallback {
@Autowired
private RabbitTemplate rabbitTemplate;
@Transactional
public void send(JSONObject jsonObject) {
String jsonString = jsonObject.toJSONString();
System.out.println("jsonString:" + jsonString);
String messAgeId = UUID.randomUUID().toString().replace("-", "");
// 封装消息
Message message = MessageBuilder.withBody(jsonString.getBytes())
.setContentType(MessageProperties.CONTENT_TYPE_JSON).setContentEncoding("utf-8").setMessageId(messAgeId)
.build();
// 构建回调返回的数据(消息id)
this.rabbitTemplate.setMandatory(true);
this.rabbitTemplate.setConfirmCallback(this);
CorrelationData correlationData = new CorrelationData(jsonString);
rabbitTemplate.convertAndSend("modify_exchange_name", "modifyRoutingKey", message, correlationData);
}
// 生产消息确认机制 生产者往服务器端发送消息的时候,采用应答机制
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
String jsonString = correlationData.getId();
System.out.println("消息id:" + correlationData.getId());
if (ack) {
log.info(">>>使用MQ消息确认机制确保消息一定要投递到MQ中成功");
return;
}
JSONObject jsonObject = JSONObject.parseObject(jsonString);
// 生产者消息投递失败的话,采用递归重试机制
send(jsonObject);
log.info(">>>使用MQ消息确认机制投递到MQ中失败");
}
}
消费者
@Component
@Slf4j
public class StockConsumer {
@Autowired
private SeckillMapper seckillMapper;
@Autowired
private OrderMapper orderMapper;
@RabbitListener(queues = "modify_inventory_queue")
@Transactional
public void process(Message message, @Headers Map<String, Object> headers, Channel channel) throws IOException {
String messageId = message.getMessageProperties().getMessageId();
String msg = new String(message.getBody(), "UTF-8");
log.info(">>>messageId:{},msg:{}", messageId, msg);
JSONObject jsonObject = JSONObject.parseObject(msg);
// 1.获取秒杀id
Long seckillId = jsonObject.getLong("seckillId");
SeckillEntity seckillEntity = seckillMapper.findBySeckillId(seckillId);
if (seckillEntity == null) {
log.warn("seckillId:{},商品信息不存在!", seckillId);
return;
}
Long version = seckillEntity.getVersion();
int inventoryDeduction = seckillMapper.inventoryDeduction(seckillId, version);
if (!toDaoResult(inventoryDeduction)) {
log.info(">>>seckillId:{}修改库存失败>>>>inventoryDeduction返回为{} 秒杀失败!", seckillId, inventoryDeduction);
return;
}
// 2.添加秒杀订单
OrderEntity orderEntity = new OrderEntity();
String phone = jsonObject.getString("phone");
orderEntity.setUserPhone(phone);
orderEntity.setSeckillId(seckillId);
orderEntity.setState(1l);
int insertOrder = orderMapper.insertOrder(orderEntity);
if (!toDaoResult(insertOrder)) {
return;
}
log.info(">>>修改库存成功seckillId:{}>>>>inventoryDeduction返回为{} 秒杀成功", seckillId, inventoryDeduction);
}
// 调用数据库层判断
public Boolean toDaoResult(int result) {
return result > 0 ? true : false;
}
}
新增对应商品令牌桶
/**
* 新增对应商品库存令牌桶
*
* @seckillId 商品库存id
*/
@RequestMapping("/addSpikeToken")
public BaseResponse<JSONObject> addSpikeToken(Long seckillId, Long tokenQuantity);
@Async
private void createSeckillToken(Long seckillId, Long tokenQuantity) {
generateToken.createListToken("seckill_", seckillId + "", tokenQuantity);
}
根据手机号码和商品库存id查询秒杀记录
public interface OrderSeckillService {
@RequestMapping("/getOrder")
public BaseResponse<JSONObject> getOrder(String phone, Long seckillId);
}
@RestController
public class OrderSeckillServiceImpl extends BaseApiService<JSONObject> implements OrderSeckillService {
@Autowired
private OrderMapper orderMapper;
@Override
public BaseResponse<JSONObject> getOrder(String phone, Long seckillId) {
if (StringUtils.isEmpty(phone)) {
return setResultError("手机号码不能为空!");
}
if (seckillId == null) {
return setResultError("商品库存id不能为空!");
}
OrderEntity orderEntity = orderMapper.findByOrder(phone, seckillId);
if (orderEntity == null) {
return setResultError("正在排队中.....");
}
return setResultSuccess("恭喜你秒杀成功!");
}
}
yml配置
logging:
level:
org.springframework.web: INFO
com.github.binarywang.demo.wx.mp: DEBUG
me.chanjar.weixin: DEBUG
###服务注册到eureka地址
eureka:
client:
service-url:
defaultZone: http://192.168.107.219:8080/eureka
server:
port: 9800
spring:
application:
name: app-mayikt-spike
redis:
host: 47.106.241.205
password: 123456
port: 6379
jedis:
pool:
max-idle: 100
min-idle: 1
max-active: 1000
max-wait: -1
###数据库相关连接
datasource:
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://47.106.241.205:3306/meite_spike
rabbitmq:
####连接地址
host: 192.168.107.219
####端口号
port: 5672
####账号
username: guest
####密码
password: guest
### 地址
#virtual-host: /admin_host
virtual-host: /
实现秒杀抢购中会遇到那些问题
前端:
1.突然增加网络访问的带宽
2.用户可能存在重复进行提交
后端:
1.秒杀抢购商品库存超卖的问题
2.
1.为什么秒杀服务需要单独独立以微服务形式部署?
目的:互不影响其他服务、docker部署快速实现扩容
2.当修改商品库存的请求增多,数据库访问压力增大,如何解决
实现分表分库、使用MQ异步实现修改库存
3.如何防止库存超卖的问题
使用数据库乐观锁(CAS无锁)
使用redis实现分布式锁
使用MQ异步形式实现修改库存(用户等待过程)
前端优化方案:
服务器带宽:1兆 2兆
1兆带宽等于128kb/s 加载一个网页640kb 640kb/128klb=5s
带宽入口问题
在一个网页中静态占了大部分整个带宽,动态资源占用带宽非常小。
http://res15.iblimg.com/respc-1/resources/v4.2/widget/footer1200/footer1200.css
http://product.bl.com/3515547.html?bl_ad=644_-366041-1&bl_mmc=YXTF-baiduPC-6xk1078b-_0
上课前体的疑问:
如果秒杀的请求的过多,对数据库频繁的io操作,可能会产生数据库崩溃问题。
分表分库、读写分离 没有用
提前生成好对应库存的令牌 存放在令牌桶中 异步发送到mq中 实现修改库存。
如果采用mq实现秒杀抢购,那么秒杀接口会立马拿到秒杀结果吗?
Mq 异步形式、高并发
1.前端调用秒杀接口如果秒杀成功的话,返回正在排队中……
2.前端写一个定时器使用秒杀token查询是否秒杀成功 MQ消费的速度非常快的情况下
12306抢票的时候 正在出票中…. 等待10秒时间告诉你 出单成功 或者是出单失败 正在排队中….
步骤实现方案:
1.后台系统在发布秒杀商品的时候,给对应的商品添加库存token
秒杀抢购
前端:
1.使用动静分离、将静态资源存放到第三方文件服务器中实现cdn加速,目的减轻秒杀抢购带宽
2.当用户点击秒杀按钮的时候,应该将按钮disabled 防止重复提交
3.使用复杂的图形验证码防止机器模拟
4.秒杀详情页面,使用定时器根据用户信息查询秒杀结果
5.商品的详情页面使用nginx+lua+openresty 实现静态化页面
网关:
1.ratelimter、nginx、hystrix、redis实现限流 令牌痛+漏铜算法 对用户秒杀请求实现限流和服务保护。
2.用户黑名单和白名单拦截
秒杀接口:
1.服务降级级、隔离、熔断
2.从redis中获取秒杀的令牌(能够获取到令牌就能够秒杀成功,否则就秒杀失败!)
3.异步使用MQ执行修改库存操作
4.提供一个根据用户信息查询秒杀结果接口
项目部署点:
Nginx+lvs 实现服务高可用和集群
其他点:
分时段抢购
具体例子:现在有100个商品同时秒杀抢购,每个商品库存为100个?
基于mq+库存令牌桶 100*100=10000 数据库执行修改库存操作压力还是非常大?
最靠谱的访问:12306 分时段秒杀 中午 下午