分布式微服务电商项目

最近用了接近两个月的时间,完成了一个分布式微服务项目,本篇博文将会对项目中涉及到的重点技术进行总结。

前言

项目中使用的一些中间件、服务软件,都是使用 docker 进行安装、配置的,如果不会 docker 安装和配置这些软件的可以参考下面这篇博文:
Docker 常用软件安装

在项目中使用到的技术:

  • 微服务框架:SpringBoot(2.2.2)、SpringCloud(Hoxton.SR1)、SpringCloudAlibaba(2.2.0.RELEASE)
  • 数据库:mysql(8.0.17)、redis
  • 持久化层框架:mybatis-plus(3.2.0)
  • 检索中间件:elasticsearch(7.4.2)
  • 分布式缓存:SpringCache
  • 分布式锁:Redisson(3.12.5)
  • 消息队列:AMQP-RabbitMQ
  • 动静分离:Nginx
  • 定时任务:Spring Schedule
  • 共享Session:spring-session
  • 模板引擎:thymeleaf

其中一些网关、服务监控、服务熔断降级限流,这些用的都是 SpringBoot、SpringCloudAlibaba 中的技术,就不再一一列举了。

--------------------------------------分割线--------------------------------------

下面,我会以一个客户进入购物网站,从登录、检索商品、加入购物车、下订单、去支付、秒杀商品,这样的流程将各个部分所用到的关键技术进行讲解,并且每个流程会附有相应的业务逻辑图,便于了解整个电商项目的流程。

一、登录

用户登录有比较重要的两点:

  1. 用户使用社交账号(微博、微信、QQ等)登录
  2. 网站内一处登录(父域名),处处(子域名)可以显示用户登录信息

1.社交账号登录

在项目中,博主使用的是微博的社交账号登录,具体可以参考微博OAuth2.0 文档使用:
微博OAuth2.0登录 - 使用接口深度开发,适合后端开发人员,在使用前需要创建一个应用项目,获取 App Key、App Secret

社交登录大致流程如下:
分布式微服务电商项目_第1张图片
使用 Access Token 通过微博API 获取用户信息:微博API

2.SpringSession 共享登录信息

在用户登录之后,对于用户本机来说,无论访问网站的哪一个页面,都应该各个微服务都应该知道当前用户的登录信息。博主在项目中,使用SpringSession 来解决 Session 共享问题

但是还有一个问题,将用户登录信息存储到 Session 中,Session 底层也是通过 Cookie 向服务器获取数据的,但是网站的不同服务,域名都不相同,为了解决这个问题,需要将保存用户信息的 Cookie 扩大域名范围到父域,这样就可以保证每个服务都可以获取到 Cookie。

使用 SpringSession 还是和 Session 一样,获取到 HttpSession 调用 session.setAttribute()方法进行数据存储,但 SpringSession 会将数据存储到 Redis 中,保证了分布式服务可以共享 Session。

SpringSession 配置类:

@Configuration
public class RedisSessionConfig {

    /**
     * cooike 序列化器:自定义 cookie 作用域
     */
    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();

        // 设置 cookie 的作用域,设置为项目的父域,所有域都可访问
        cookieSerializer.setDomainName("mall.com");

        return cookieSerializer;
    }

    /**
     * Redis 序列化器:设置存入 Redis 中的序列化机制
     */
    @Bean
    public RedisSerializer<Object> redisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

登录简单业务梳理:
分布式微服务电商项目_第2张图片

二、检索商品

检索商品主要就是对 ElasticSearch 的使用,项目中有以下两点:

  1. 商品上架:需要根据商品信息的字段提前创建索引以及各个字段的映射关系,并保存输入商品信息;
  2. 商品检索:根据检索参数动态构建DSL语句,根据DSL语句向ES服务器发送检索请求,获取到检索响应封装为指定数据返回

检索商品简单业务梳理:
分布式微服务电商项目_第3张图片

三、加入购物车

将商品加入购物车有一个关注点:

  • 临时用户与登录用户:临时用户也是可以添加商品到购物车的,并且关闭页面之后,下一次还能查看之前添加的商品

为了解决这个业务问题,使用了两个购物车数据结构来分别将 临时购物车、用户购物车 存储到 Redis 中,并且在用户登录查看购物车时,会合并临时购物车的商品到用户购物车中。

解决方法:创建拦截器对于每一个没有登录的用户,在使用购物车服务时,会分配一个 user-key并保存到 Cookie 中,设置 Cookie 保存1个月。这样就可以保证1个月内,临时用户可以查看自己添加过的商品。

CartInterceptor 拦截器类:

public class CartInterceptor implements HandlerInterceptor {

    public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();

    /**
     * 业务执行之前:检测用户登录状态,如果是临时用户分配一个 user-key
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserInfoTo userInfoTo = new UserInfoTo();

        HttpSession session = request.getSession();
        MemberRespVo member = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
        // 1.用户登录:
        if (member != null) {
            userInfoTo.setId(member.getId());
        }
        // 2.临时用户:如果之前已经存在就从浏览器中获取 user-key
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
                    userInfoTo.setUserKey(cookie.getValue());
                    userInfoTo.setTempUser(true); // 标记已经是临时用户,没有必要保存 cookie
                }
            }
        }

        // 3.临时用户:在第一次使用购物车时,自动生成一个 user-key,并会在 postHandle 中将 user-key 保存到浏览器的 cookie 中
        if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
            String userKey = UUID.randomUUID().toString();
            userInfoTo.setUserKey(userKey);
        }

        // 在方法执行之前,将用户的登录状态存入到 ThreadLocal 中,方便后面 controller 的执行
        threadLocal.set(userInfoTo);
        return true;
    }

    /**
     * 业务执行之后:
     *  如果第一次访问购物车,保存临时用户的 user-key 到浏览器 cookie 中,保证以后每次访问都会携带这个 user-key
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserInfoTo userInfoTo = threadLocal.get();
        // 如果是第一次使用购物车,才会在浏览器 cookie 中保存一个 user-key
        if (!userInfoTo.isTempUser()) {
            Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
            cookie.setDomain("mall.com");
            cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT); // 设置1个月的过期时间
            response.addCookie(cookie);
        }
    }
}

在 Redis 保存每个购物车的商品信息时,使用的是 hash 类型,使用 hash 类型可以使查看购物车项、结算等业务逻辑都非常快速。

购物车简单业务梳理:
分布式微服务电商项目_第4张图片

四、下订单

订单服务所涉及到的就是多服务的分布式事务问题,如何保证订单的创建、库存的扣减、订单的回滚、库存的回滚是下订单关注的问题:

  1. SpringCloudAlibabaSeata:一个保证分布式服务事务的框架,但是由于锁太重,不利于与用户流量较大的订单服务,而利于后台对商品上架等业务
  2. 分布式事务(柔性事务 + 可靠消息 + 最终一致性):使用 RabbitMQ 来进行服务之间的消息沟通,保证订单与库存的最终一致性,使用消息队列的好处在于处理业务迅速

博主在项目中的下订单以及库存扣减,都是采用 RabbitMQ 来保证订单服务与库存服务之间事务的一致性。

这里先插入一张图,可以简单了解一下 RabbitMQ 的工作流程:
分布式微服务电商项目_第5张图片
在消息队列中,可以使用死信队列来控制订单的有效时间,如果订单在有效时间内没有完成支付,死信队列就会向库存服务发送解锁库存的消息,在库存服务手动将库存数量将会回滚。

订单服务与库存服务之间消息队列工作流程图:
分布式微服务电商项目_第6张图片

五、支付

因为只是一个简单的项目,所以我使用的是第三方支付宝沙箱环境进行的支付测试,不了解支付宝沙箱环境可以参考:沙箱环境

在支付过程中,支付宝使用了 RSA 非对称加密算法来保证支付过程的安全,对于 RSA 加密算法可以通过下面的图简单了解一下:
分布式微服务电商项目_第7张图片
所以在使用支付接口时,我们需要给支付宝上传一个商户公钥(商户私钥自己安全保管),支付宝会给我们一个支付宝公钥。在支付过程中,我们使用商户私钥对数据进行加签加密,支付宝通过商户公钥进行验签解密,将响应再次通过支付宝私钥加签加密发送给我们,我们在使用支付宝公钥解签解密,才能获取到支付的响应结果。

调用支付宝支付的模板类:

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {

    //在支付宝创建的应用的id
    private String app_id = "2016102600763438";

    // 商户私钥,您的PKCS8格式RSA2私钥
    private String merchant_private_key = xxx;
    // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
    private String alipay_public_key = xxx;
    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    private String notify_url;

    // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    private String return_url;

    // 签名方式
    private String sign_type = "RSA2";

    // 字符编码格式
    private String charset = "utf-8";

    // 订单超时时间
    private String timeout = "30m";

    // 支付宝网关; https://openapi.alipaydev.com/gateway.do
    private String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";

    public String pay(PayVo vo) throws AlipayApiException {

        //AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
        //1、根据支付宝的配置生成一个支付客户端
        AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
                app_id, merchant_private_key, "json",
                charset, alipay_public_key, sign_type);

        //2、创建一个支付请求 //设置请求参数
        AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
        alipayRequest.setReturnUrl(return_url);
        alipayRequest.setNotifyUrl(notify_url);

        //商户订单号,商户网站订单系统中唯一订单号,必填
        String out_trade_no = vo.getOut_trade_no();
        //付款金额,必填
        String total_amount = vo.getTotal_amount();
        //订单名称,必填
        String subject = vo.getSubject();
        //商品描述,可空
        String body = vo.getBody();

        alipayRequest.setBizContent("{\"out_trade_no\":\"" + out_trade_no + "\","
                + "\"total_amount\":\"" + total_amount + "\","
                + "\"subject\":\"" + subject + "\","
                + "\"body\":\"" + body + "\","
                + "\"timeout_express\":\"" + timeout + "\","
                + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

        String result = alipayClient.pageExecute(alipayRequest).getBody();

        //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
        System.out.println("支付宝的响应:" + result);

        return result;

    }
}

如果支付完成,支付宝会有两个返回(直接返回、异步回调),直接返回跳转到用户的订单列表,而异步回调则可以对订单进行后续处理(解锁库存、修改订单状态等等),而且异步回调的速度快与直接返回。 使用异步回调需要先支付宝服务器返回一个 success,否则支付宝服务器会一直发送这个异步请求(具体参考:支付宝异步回调)。

处理异步回调方法:

	/**
     * 支付宝异步回调通知:https://opendocs.alipay.com/open/270/105902
     */
    @PostMapping("/payed/notify")
    public String handleAilpayed(HttpServletRequest request, PayAsyncVo vo) throws AlipayApiException, UnsupportedEncodingException {
        System.out.println("进入支付异步回调...");
        // 只要我们收到了支付宝给我们异步的通知,告诉我们订单支付成功,返回 success,支付宝不在通知
        // 1.验签
        // 获取支付宝POST过来反馈信息
        Map<String,String> params = new HashMap<String,String>();
        Map<String,String[]> requestParams = request.getParameterMap();
        for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
            String name = (String) iter.next();
            String[] values = (String[]) requestParams.get(name);
            String valueStr = "";
            for (int i = 0; i < values.length; i++) {
                valueStr = (i == values.length - 1) ? valueStr + values[i]
                        : valueStr + values[i] + ",";
            }
            // 乱码解决,这段代码在出现乱码时使用
//            valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
            params.put(name, valueStr);
        }

        boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(), alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名

        // 2.签名验证成功,处理订单
        if (signVerified) {
            String result = orderService.handlePayResult(vo);
            System.out.println("签名验证成功..");
            return result;
        }
        return "error";
    }

支付业务梳理:
分布式微服务电商项目_第8张图片

六、秒杀

商品秒杀可以说是比较经典的高并发场景,如何让自己的应用承受百万级的并发是非常重要的,下面提出几个高并发场景需要注意的问题:

  1. 服务单一职责+独立部署:保证秒杀服务单独部署,即使挂掉,也不会影响别的服务运行;
  2. 秒杀链接加密:防止恶意攻击,要保证秒杀链接是在开始秒杀的那一刻才能访问,防止提前秒杀商品;
  3. 库存预热+快速扣减:在秒杀开始前,提前将需要秒杀的商品存储到 Redis 中,库存的扣减可以使用信号量来控制;
  4. 动静分离:保证秒杀和商品详情页的动态请求才能进入后台服务集群,而一些静态资源访问 Nginx 就可以;
  5. 恶意请求拦截:识别非法攻击请求并进行拦截,一般在网关层;
  6. 流量错峰:将瞬时流量使用各种手段分担到一个时间段,比如:秒杀时输入验证码、加入购物车等操作;
  7. 限流&熔断&降级:限制次数,限制总量,快速失败降级,熔断隔离防止雪崩;
  8. 队列削峰:可以将秒杀成功的请求,进入队列,而订单服务监听队列,慢慢创建队列,扣减库存即可。

有了上面的这些方法,再加上一些集群的部署,处理百万并发也不是不可能。博主在项目中也是尽量满足了上面的部分条件:

  1. 秒杀服务的单独部署
  2. 为每个秒杀商品添加了秒杀随机码,只有在秒杀时间才能获取到,并且秒杀商品必须带有随机码才能进行秒杀,保证了秒杀链接加密
  3. 将近3天的秒杀商品都存储到 Redis 中,并且使用分布式锁 Redisson 的信号量来对商品库存进行扣减
  4. 使用 Nginx 来保证动静分离
  5. SpringCloud Gateway 网关服务会拦截恶意请求
  6. 使用 SpringCloudAlibaba Sentinel 来对服务进行限流、熔断、降级
  7. 使用 RabbitMQ 来完成队列削峰,商品秒杀成功后,会向订单服务发送一个消息,订单服务监听消息,完成后续的订单业务逻辑。

还有一个需要注意的就是对于秒杀商品的上架,这里博主使用了 Spring 自带的定时任务注解@EnableScheduling @Scheduled(cron = "0 0 3 * * ?"),定时任务在每天的3点进行秒杀商品上架,上架最近3天的秒杀场数据。

秒杀业务梳理:
分布式微服务电商项目_第9张图片
在秒杀流程中,这些操作都没有数据库操作、服务调用等,每个步骤执行速度非常快,提高秒杀业务处理吞吐量。

七、其他技术补充

1.OpenFeign 的远程调用,出现消息头丢失问题

问题原因由于使用 Feign 进行远程调用时,会创建代理对象重新封装一个 RequestTemplate,但是这个请求里面并没有携带原 Request 的请求头 Cookie 信息,导致远程调用访问 Session 内容为空,导致服务之间无法获取到 Session 中存储的用户信息

解决方法在 Feign 的代理对象构建 RequestTemplate 过程中会提供一个 RequestInterceptor 拦截器处理,我们可以利用这个机制来对RequestTemplate 进行修改,将原 Request 请求中的 Cookie 信息,添加到新的 RequestTemplate 中。

Feign 的配置类:

@Configuration
public class MyFeignConfig {
    
    @Bean
    public RequestInterceptor requestInterceptor() {
        return template -> {
            // 1.使用 RequestContextHolder 拿到原请求
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

            if (attributes != null) {
                HttpServletRequest request = attributes.getRequest();

                // 2.将原请求中的 Cookie 信息添加到新的 RequestTemplate 中
                String cookie = request.getHeader("Cookie");
                template.header("Cookie", cookie);
            }
        };
    }
}

2.异步编排,无法获取主线程 Request 请求

上面为了解决 OpenFeign 远程调用出现的消息头丢失问题,我们可以从RequestAttributes 中获取到原请求,将原请求的 Cookie 设置给 Feign RequestTemplate,但是在异步线程 Feign 拦截器调用 RequestContextHolder.getRequestAttributes() 时,只是新线程的 RequestAttributes,在新线程的 RequestAttributes 中并没有存储主线程的 Request 请求,所以在异步任务 Feign 调用时会出现上下文丢失的情况。

main -> ThreadLocal (RequestAttributes(main) -> "/toTrade"请求的相关信息)
          main("/toTrade") --- confirmOrder() ------------------------
                               thread1 -> ThreadLocal (RequestAttributes(thread1) -> null)
                               thread1:addressFuture ----------
                               thread2 -> ThreadLocal (RequestAttributes(thread2) -> null)
                               thread2:cartItemsFuture --------

解决方法:在开启异步任务之前,将主线程的 RequestAttributes(main) 存储到异步线程的 RequestAttributes 中进行线程间共享,这样别的线程就可以通过主线程的 RequestAttributes 获取到请求上下文信息。

// 获取主前线程的 RequestAttributes
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

// 1.远程查询所有的收货列表
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
    // 将主线程的 RequestAttributes 设置到自己 ThreadLocal 中
    RequestContextHolder.setRequestAttributes(requestAttributes);
    List<MemberAddressVo> address = memberFeignService.getAddress(memberId);
    confirmVo.setAddressVos(address);
}, executor);

八、总结

花了2个月的时间完成一个分布式项目还是非常有意义的,在这个项目中学到了非常多的东西,也巩固了之前学过的东西,总的来说对自己帮助还是非常大的,继续淦!

项目 GitHub 仓库地址:
https://github.com/zk-kiger/Shopping-Mall

你可能感兴趣的:(微服务开发)