分布式实战 (dubbo+zookeeper,redis,elastic search+kibana,mysql,jwt) 常见的电商项目中秒杀商品模块编写思路
秒杀服务模块
"秒杀"是网络卖家发布的一种超低价格的商品,所有买家在同一时间抢购的一种消费方式。秒杀技术实现的核心思想是使用缓存技术减轻数据库的压力。在秒杀是首先会将秒杀商品从数据库同步到缓存中,用户在缓存中查询秒杀商品redis的读写效率要比mysql高
抢购时减少缓存中的商品数量。秒杀商品的
用户访问量以及并发量是比较大的,数据库并承受不了那么大的压力,产生的秒杀商品用户先写入缓存,当用户完成付款后,再将订单数据保存到数据库。
单点登录
单点登录,在用户登录一次后,可以访问所有相互信任的应用系统
JWT分布式鉴权
一般的电商项目在用户完成登录后会在头部显示欢迎回来xxx
,在分布式项目中使用session保存用户信息是不行的,session只能保存在当前服务器,而分布式项目是有很多服务器的,此时我们需要使用单点登录这一技术来获取用户信息。
单点登录功能编写:
- 编写JWT工具类(生成令牌和解析令牌的方法)
- 用户登录成功后返回令牌字符串给前端
- 前端每次请求都在请求头加上令牌字符串(token)
- 编写拦截器,解析请求头中的令牌字符串
- 在api模块配置拦截器,指定需要拦截哪些接口,即需要登录才能访问的接口。
一般都是拦截所有接口,放行个别接口
在后台管理系统我们可以通过Spring Security技术来获取认证用户的信息
秒杀商品服务接口(编写在通用模块)
我们采用的是dubbo+zookeeper这样的一个注册中心,使用Dubbo时服务的生产者和消费者都需要引入服务接口,所以我们要构建一个通用模块。除了编写服务接口还可以编写一些工具类,实体类,统一异常处理器等等。
- 将mysql中秒杀商品数据同步到redis中
- 根据商品id查询秒杀订单
- 生成订单数据保存到redis中
- 根据订单id查询订单数据
- 支付秒杀订单
// 秒杀商品服务
public interface SeckillService {
Page<SeckillGoods> findPageByRedis(int page, int size);
SeckillGoods findSeckillGoodsByRedis(Long goodsId);
Orders createOrder(Orders orders);
Orders findOrder(String id);
Orders pay(String orderId);
}
秒杀商品服务实现类(dubbo服务提供者)
将数据库中的秒杀商品同步到redis中
这里使用的是Spring Task定时任务需要在spring的启动类上方添加EnableScheduling注解开启定时任务
,在开发环境中为了方便测试,每5秒同步一次数据,在生产环境可以设置成每分钟同步一次数据。
将秒杀商品同步到reids中,需要自己构建查询条件,秒杀商品即
startTime<当前时间<endTime
并且库存>0
,也就是说用户看到的秒杀商品都是正在秒杀的商品且有库存的。
@Scheduled(cron = "0/5 * * * * *")
public void refreshRedis(){
// 将redis中的商品库存数同步到mysql中
List<SeckillGoods> seckillGoodsList = redisTemplate.boundHashOps("seckillGoods").values();
for (SeckillGoods seckillGoods : seckillGoodsList) {
SeckillGoods goods = seckillGoodsMapper.selectById(seckillGoods.getId());
goods.setStockCount(seckillGoods.getStockCount());
seckillGoodsMapper.updateById(goods);
}
System.out.println("将mysql中的数据同步到redis中");
QueryWrapper<SeckillGoods> queryWrapper = new QueryWrapper();
Date date = new Date();
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
queryWrapper.le("startTime",now)
.ge("endTime",now)
.gt("stockCount",0);
// 查询出正在秒杀的商品
List<SeckillGoods> seckillGoods = seckillGoodsMapper.selectList(queryWrapper);
// 删除redis中的秒杀商品数据
redisTemplate.delete("seckillGoods");
// 将数据同步到redis中
for (SeckillGoods seckillGood : seckillGoods) {
redisTemplate.boundHashOps("seckillGoods").put(seckillGood.getGoodsId(),seckillGood);
}
}
在Redis中查询所有秒杀订单
前端还是比较喜欢处理Mybatis-Plus
的分页数据的,MP的页数是从1开始的补充:ES的分页是从0开始的
,这里为了方便前端操作我们需要自己构建MP的分页对象,即当前页,每页条数,总条数,返回结果集。
@Override
public Page<SeckillGoods> findPageByRedis(int page, int size) {
List<SeckillGoods> seckillGoodsList = redisTemplate.boundHashOps("seckillGoods").values();
int start = (page - 1) * size;
int end = start + size > seckillGoodsList.size() ? seckillGoodsList.size() : start + size;
List<SeckillGoods> seckillGoods = seckillGoodsList.subList(start, end);
Page<SeckillGoods> page1 = new Page();
page1.setCurrent(page)
.setSize(size)
.setTotal(seckillGoodsList.size())
.setRecords(seckillGoods);
return page1;
}
根据商品id查询出正在秒杀的商品
@Override
public SeckillGoods findSeckillGoodsByRedis(Long goodsId) {
SeckillGoods seckillGoods = (SeckillGoods) redisTemplate.boundHashOps("seckillGoods").get(goodsId);
return seckillGoods;
}
生成订单
订单的id为雪花算法生成的id是个字符串,在分布式的项目中使用雪花算法生成的id基本上不会重复,
这里需要返回一个订单对象给前端,前端会拿到订单id,方便下面完成根据订单id查询订单的功能。
在生成订单后需要在缓存中减少库存,并且在同步方法中修改mysql中的数据,避免秒杀商品无线循环。
为什么会出现秒杀商品无线循环的问题呢?
因为我们是将mysql的数据同步到redis中,用户生成订单后订单库存减少,此时redis中的库存发生变化,但mysql中的库存并没有发生变化,而我们的定时任务是每5秒进行同步数据,这时候就会出现一个问题,用户明明生成了订单但库存并没有变化,此时我们需要修改定时任务的方法,将redis的库存同步到mysql中。
订单超时回退库存解决方案
当redis中的数据过期时,我们仍然可以拿到key但拿不到value即订单详情,此时我们需要创建一个订单副本即订单过期时间要比原订单要长,当用户下单之后在5分钟内未付款删除redis中的订单数据并使商品回退库存。
创建redis配置类
@Configuration
public class RedisListenerConfig {
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory){
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(connectionFactory);
return redisMessageListenerContainer;
}
}
创建redis监听过期订单的方法
注意:当订单过期后触发该监听方法使商品回退库存,此时我们需要将副本也删除掉,副本也是有过期时间的当副本过期后触发该方法没有意义,在订单支付成功后我们也应该将副本删除。
/**
* 监听过期订单
*/
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SeckillService seckillService;
public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String orderId = message.toString();
Orders orders = (Orders) redisTemplate.opsForValue().get(orderId + "_copy");
Long goodId = orders.getCartGoods().get(0).getGoodId();
Integer num = orders.getCartGoods().get(0).getNum();
SeckillGoods seckillGoods = seckillService.findSeckillGoodsByRedis(goodId);
seckillGoods.setStockCount(seckillGoods.getStockCount()+num);
redisTemplate.boundHashOps("seckillGoods").put(goodId,seckillGoods);
redisTemplate.delete(orderId+"_copy");
}
}
@Override
public Orders createOrder(Orders orders) {
orders.setId(IdWorker.getIdStr());
orders.setCreateTime(new Date());
orders.setExpire(new Date(new Date().getTime()+1000*60*5));
CartGoods cartGoods = orders.getCartGoods().get(0);
Integer num = cartGoods.getNum();
BigDecimal price = cartGoods.getPrice();
BigDecimal sum = price.multiply(BigDecimal.valueOf(num));
orders.setPayment(sum);
SeckillGoods seckillGoods = findSeckillGoodsByRedis(cartGoods.getGoodId());
Integer stockCount = seckillGoods.getStockCount();
if(stockCount <= 0){
throw new BusException(CodeEnum.STOCK_COUNT_ERROR);
}
seckillGoods.setStockCount(stockCount - num);
redisTemplate.boundHashOps("seckillGoods").put(seckillGoods.getGoodsId(),seckillGoods);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.opsForValue().set(orders.getId(),orders,1, TimeUnit.MINUTES);
redisTemplate.opsForValue().set(orders.getId()+"_copy",orders,2,TimeUnit.MINUTES);
return orders;
}
根据订单id查询订单
@Override
public Orders findOrder(String id) {
Orders orders = (Orders) redisTemplate.opsForValue().get(id);
return orders;
}
支付订单
支付功能的编写:
使用的是一个支付宝沙箱环境进行测试,需要自己去申请,拿到支付宝公钥用于我们和支付宝交互,获取应用私钥用于支付宝和我们交互生成二维码需要封装请求参数out_trade_no、total_amount、subject
这些参数分别是订单编号、支付金额、主题。
以下代码仅供参考:
@Override
public String pcPay(Orders orders) {
// 创建支付请求
AlipayTradePrecreateRequest alipayTradePrecreateRequest = new AlipayTradePrecreateRequest();
// 设置请求内容
alipayTradePrecreateRequest.setNotifyUrl(zfbPayConfig.getNotifyUrl()+zfbPayConfig.getPcNotify());
JSONObject jsonObject = new JSONObject();
jsonObject.put("out_trade_no",orders.getId()); // 订单编号
jsonObject.put("total_amount",orders.getPayment()); // 支付金额
jsonObject.put("subject",orders.getCartGoods().get(0).getGoodsName()); // 订单标题
alipayTradePrecreateRequest.setBizContent(jsonObject.toString());
// 发送请求
try {
AlipayTradePrecreateResponse alipayTradePrecreateResponse = alipayClient.execute(alipayTradePrecreateRequest);
// 生成二维码
return alipayTradePrecreateResponse.getQrCode(); // 二维码字符串
} catch (AlipayApiException e) {
throw new BusException(CodeEnum.ZFB_PAY_ERROR);
}
}
@Override
public void checkSign(Map<String, Object> paramMap) {
// 获取参数
Map<String,String[]> requestParameterMap = (Map<String, String[]>) paramMap.get("requestParameterMap");
// 验签
boolean valid = ZfbVerifierUtils.isValid(requestParameterMap, zfbPayConfig.getPublicKey());
if(!valid){
throw new BusException(CodeEnum.CHECK_SIGN_ERROR);
}
}
@PostMapping("/pcPay")
public BaseResult<String> pcPay(String orderId){
Orders orders = orderService.findById(orderId);
String pay = zfbPayService.pcPay(orders);
return BaseResult.ok(pay);
}
@PostMapping("/success/notify")
public BaseResult successNotify(HttpServletRequest request){
// 验签
Map<String,Object> map = new HashMap();
map.put("requestParameterMap",request.getParameterMap());
zfbPayService.checkSign(map);
String trade_status = request.getParameter("trade_status"); // 订单状态
String out_trade_no = request.getParameter("out_trade_no"); // 订单编号
// 付款成功
if(trade_status.equals("TRADE_SUCCESS")){
// 修改订单状态
Orders orders = orderService.findById(out_trade_no);
orders.setPaymentType(2); // 支付方式
orders.setStatus(2); // 订单状态
orders.setPaymentTime(new Date()); // 付款时间
orderService.update(orders);
// 新增交易记录
Payment payment = new Payment();
payment.setOrderId(out_trade_no);
payment.setTransactionId(out_trade_no);
payment.setTradeType("支付宝支付");
payment.setTradeState(trade_status);
payment.setContent(JSON.toJSONString(request.getParameterMap()));
payment.setCreateTime(new Date());
payment.setPayerTotal(orders.getPayment());
zfbPayService.addPayment(payment);
}
return BaseResult.ok();
}
@Override
public Orders pay(String orderId) {
// 查询订单设置订单数据
Orders orders = (Orders) redisTemplate.opsForValue().get(orderId);
if(orders == null){
throw new BusException(CodeEnum.ORDER_PAY_ERROR);
}
orders.setPaymentTime(new Date());
orders.setPaymentType(2);
orders.setStatus(2);
// 从redis中删除订单数据
redisTemplate.delete(orderId);
// 删除复制订单数据
redisTemplate.delete(orderId+"_copy");
return orders;
}