本文介绍一个由多个微服务实现的完整业务应用利用MSHA产品改造为异地多活架构的过程。

前提条件

开通产品

  1. 公测阶段,您需要申请开通MSHA
  2. 确定您需要的多活架构类型(仅异地、仅同城、异地+同城),本文示例以仅异地为例。
  3. 确定多活业务类型,例如电商业务可以申请两种业务类型:导购和交易。若您无特殊需求,则不需要处理,MSHA会为您生成默认业务类型。

改造架构

假设这个业务由以下微服务共同实现,服务发现依赖于K8s实现,服务间调用利用Feign实现。

  • frontend,一个传统的MVC服务。负责和用户交互。
  • cartservice,购物车服务。记录用户的购物车数据,自建Redis存储。
  • productservice,产品服务。存储商品信息,包含商品详情、库存等,自建MySQL存储。
  • checkoutservice,下单服务。从购物车拉取商品信息,并从产品服务检验库存,完成下单,自建MySQL存储。
架构1

为了演示方便,本文示例仅将下单服务进行单元化改造,改造完成后,下单服务会有单元保护、跨单元订单操作等功能,为此需要将自建MySQL换成阿里云RDS,如下图所示。

架构2

主要原因是自建MySQL非标准数据库,MSHA无法管控,后续阿里云会提供一系列的标准,并支持符合这些标准的自建MySQL。

控制台接入

  1. 配置多活空间。
    1. 登录AHAS控制台,然后在页面左上角选择地域,在控制台左侧导航栏中选择多活容灾
    2. 在左侧导航栏选择基础配置 > 新建多活空间
    3. 填写多活空间名称,例如交易单元化的正式环境,导购单元化的测试环境。
    4. 在下拉列表中选择多活业务类型,然后选择启用的多活组件。

      多活业务类型是在开通产品时确定的,本示例中选择启用接入层和数据层。

    5. 设置接入层路由标提取方式

      路由标提取方式支持Header和Cookie的排列组合提出方式,本示例先从HTTP Header中找routerId的值,若没找到再从HTTP Cookie中找routerId的值。

      最佳实践1
    6. 单击下一步,配置异地单元。本文以杭州、北京两地为例。
    7. 单击+修改,为每个单元配置相关信息,包含ACM以及接入层集群。然后单击确定
      最佳实践
    8. 单击下一步,添加路由标解析规则。
      路由标解析规则是指路由标到路由ID的解析过程,本示例中routeId=111123,按照10000取模,那么路由ID就是1123。最佳实践3
    9. 单击确定,添加单元分流。即哪些ID到哪些单元,例如0~4999到杭州,5000~9999到北京。然后单击确定
  2. 配置接入层。
    1. 在左侧导航栏选择接入层配置 > 接入域名配置
    2. 单击右上角的新增域名,录入域名。详情请参见配置接入层。然后单击确定
      说明
      • 如果是阿里云DNS域名解析类型请选择DNS解析,如果是其它供应商请选择不解析。
      • 纠错类型支持两种:反向代理和重定向,用户可按需选择。

      本文示例如下。

      设置域名
    3. 单击右上角的新增域名接入类型选择IP,录入URI,然后单击确定
      本文示例中的这两个IP分别是杭州和北京单元的Frontend Service对外提供的IP,可以是真实IP,也可以是VIP。
    4. 录入完成后,单击操作列的生效
      域名生效
  3. 配置异地数据层。
    1. 在左侧导航栏选择数据层配置 > 异地数据层配置
    2. 单击配置多活属性,选择需要同步的实例。

      本文示例中是杭州和北京各自的checkout-RDS。

      配置多活属性
    3. 单击创建同步链路。根据您能接受的同步延迟时间和成本,自主选择同步链路规格。
    4. 单击下一步,配置两个实例各自的数据库和表。
    5. 单击下一步,预览链路。
      配置完成后预览,可见数据已经在同步中。配置链路

改造数据面

  • 服务层

    由于服务层无多活逻辑,所以您仅需做多活参数透传,也就是routerIdunitType(如果仅default的话,就不需要传)。

    对于unitType,接入层会在用户请求Header加上,所以服务层可以直接从Header中获取;对于routerId,则需要服务层按照自己的配置进行解析(自然的前端在发起请求的时候必须在Header或Cookie中带上routerId参数),按照在全局配置引导中的配置,frontend解析多活参数的代码如下。

    @Component
    public class UnitLogicInterceptor extends HandlerInterceptorAdapter {
        private Logger logger = LoggerFactory.getLogger(UnitLogicInterceptor.class);
        private static final String DEFAULT_UNIT_TYPE = "unit_type";
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
                throws Exception {
            if (cookies != null){
                for (Cookie cookie : cookies) {
                    if ("routerid".equalsIgnoreCase(cookie.getName())){
                        // 放进thread local便于透传。
                        threadLocalRouterId.set(Long.valueOf(cookie.getValue()));
                    }
                    if ("Unit-Type".equalsIgnoreCase(cookie.getName())){
                        threadLocalUnitType.set(cookie.getValue());
                    }
                }
            }
               Enumeration<String> headerNames = request.getHeaderNames();
            if (headerNames != null) {
                while (headerNames.hasMoreElements()) {
                    String name = headerNames.nextElement();
                    if ("routerid".equalsIgnoreCase(name)){
                        threadLocalRouterId.set(Long.valueOf(request.getHeader(name)));
                    }
                    if ("Unit-Type".equalsIgnoreCase(name)){
                        threadLocalUnitType.set(request.getHeader(name));
                    }
                }
            }
            if (threadLocalUnitType.get() == null){
                // 为后面跳过接入层直接访问服务层作准备。
                threadLocalUnitType.set(DEFAULT_UNIT_TYPE);
                logger.info("default setUnitType {}", DEFAULT_UNIT_TYPE);
            }
            return true;
        }
    
        @Override
        public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
                throws Exception {
            threadLocalRouterId.remove();
            threadLocalUnitType.remove();
        }
    
    }
    frontend需要将多活参数透传给checkoutservice,有以下两种做法:
    • 隐式透传,利用请求附加属性透传,让checkoutservice自行解析。比如Feign的requestInterceptor,dubbo的context,请参见context
    • 显示透传,直接改造service签名,加入多活参数,本Demo用第二种。
    @Controller
    public class AppController {
        public static ThreadLocal<Long> threadLocalRouterId = new ThreadLocal<>();
        public static ThreadLocal<String> threadLocalUnitType = new ThreadLocal<>();
        
        @PostMapping("/checkout")
        @ResponseBody
        public Map<String, String> checkout(@RequestParam(name = "email") String email,
                                            @RequestParam(name = "street_address") String streetAddress,
                                            @RequestParam(name = "zip_code") String zipCode,
                                            @RequestParam(name = "city") String city,
                                            @RequestParam(name = "state") String state,
                                            @RequestParam(name = "country") String country,
                                            @RequestParam(name = "credit_card_number") String creditCardNumber,
                                            @RequestParam(name = "credit_card_expiration_month") int creditCardExpirationMonth,
                                            @RequestParam(name = "credit_card_cvv") String creditCardCvv) {
            String orderId = orderDAO.checkout(email, streetAddress, zipCode,
                                               city, state, country, creditCardNumber,
                    creditCardExpirationMonth, creditCardCvv, 
                    threadLocalRouterId.get()+"",threadLocalUnitType.get());
            
            return new HashMap<String,String>(2){{
                        put("status","302");
                        put("location","/checkout/" + orderId+"/"+threadLocalRouterId.get());
            }};
        }
    }
    
    @Service
    public class OrderDAO {
        @Autowired
        private CheckoutServiceInner checkoutService;
    
        public String checkout(String email, String streetAddress, String zipCode, String city, String state, String country,
                               String creditCardNumber, int creditCardExpirationMonth, String creditCardCvv, String userId, String unitType) {
            return checkoutService.checkout(email, streetAddress, zipCode,
                    city, state, country,  creditCardNumber,
                    creditCardExpirationMonth, creditCardCvv, userId,unitType);
        }
    
        @FeignClient(name = "checkoutservice")
        public interface CheckoutServiceInner {
            @PostMapping("/checkout0")
            String checkout(@RequestParam("email") String email,
                            @RequestParam("streetAddress") String streetAddress, 
                            @RequestParam("zipCode") String zipCode,
                            @RequestParam("city") String city,
                            @RequestParam("state") String state, 
                            @RequestParam("country") String country,
                            @RequestParam("creditCardNumber") String creditCardNumber,
                            @RequestParam("creditCardExpirationMonth") int creditCardExpirationMonth,
                            @RequestParam("creditCardCvv") String creditCardCvv,
                            @RequestParam("userId") String userId,
                            @RequestParam("unitType") String unitType
                           
            );
        }
    }
  • 数据层
    1. 执行以下命令,数据层引入msha-client。
      <dependency>
         <groupId>com.aliyun.unit.router</groupId>
         <artifactId>msha-sdk-client</artifactId>
         <version>1.0.4-SNAPSHOT</version>
      </dependency>
    2. 启动参数指定ramrole(也可选AK/SK的方式),以及ACM的Server地址ACMDOMAIN和命名空间ACMTENANT,这样msha-client才能从ACM获取单元规则。

      说明 如果应用不是在阿里云,则还需要手动指定Region-ID和Zone-ID(要与全局配置引导保持一致),这样msha-client才知道本地属于哪个单元。
      java -Dspring.profiles.active=$UNITFLAG -Dram.role.name=acm-role 
      -Daddress.server.domain=$ACMDOMAIN 
      -Dtenant.id=$ACMTENANT 
      -jar /app/checkoutservice-provider-0.0.1-SNAPSHOT.jar
    3. 数据层操作数据库时,调用msha-client传递多活参数。
      @Override
          public String checkout(String email, String streetAddress, String zipCode, String city, String state, String country,
                                 String creditCardNumber, int creditCardExpirationMonth, String creditCardCvv, String userId, String unitType) {
      
              Order order =new Order();
              UUID uuid = UUID.randomUUID();
              order.setOrderId(uuid.toString());
              order.setUserId(userId);
              //获取购物车商品。
              List<CartItem> items = cartDao.cleanCartItems(userId);
              List<ProductItem> productItems = new ArrayList<>();
              for (CartItem item : items) {
                  productItems.add(new ProductItem(item.getProductID(), item.getQuantity(), order.getOrderId(),item.getProductName()));
              }
              //校验库存。
              List<ProductItem> lockedProductItems = productDao.confirmInventory(productItems);
              //保存商品列表。
              order.setProductItemList(productItems);
              int lockedProductNum = 0;
              for (ProductItem item : lockedProductItems) {
                  if (item.isLock()) {
                      lockedProductNum++;
                  }
              }
              if (lockedProductNum > 0) {
                  //状态为1表示至少有一件商品购买成功。
                  order.setStatus(1);
                  //计算价格。
                  //校验、保存地址。
                  //生成订单,支付。
                  //运输商品。
              } else {
                  //表示所有商品都购买失败。
                  order.setStatus(-1);
              }
              OrderForm orderForm = new OrderForm(order);
              logger.info("orderForm {} order {}",orderForm ,order);
              try {
                  // 传递参数。
                  RouterContextClient.setUnitContext(userId,unitType);
                  orderFormRepository.save(orderForm);
              }catch (Exception e){
                  logger.error("save order error",e);
                  for (CartItem item : items) {
                      // 购物车回滚。
                      cartDao.addToCart(item, Long.valueOf(userId));
                  }
                  // 返回错误信息。
                  return "error:"+e.getCause().getCause().getMessage();
              }
              // 清理。
              RouterContextClient.clearUnitContext();
              return order.getOrderId();
          }

功能演示

  • 接入层分流

    对不同ID用户,接入层会将用户分流到规则确定的单元。

  • 单元保护

    假设接入层请求打错了,数据层能够将该请求拦截,避免数据脏写。本文示例以跳过接入层,直接访问服务模拟这个过程。

  • 切流

    假设某个单元出了故障,您需要将用户流量切走,让另一个单元为其提供服务。本文示例以挂掉杭州单元的productservice来模拟故障,此时这些0~4990 ID的用户都无法使用服务,在MSHA控制台进行切流操作,这样这部分ID就能使用北京单元提供的服务了。

    可以看到购物车cartservice没做单元化,所以切流后的购物车就没数据了,但是订单checkoutservice做了单元化,所以切流后用户仍可以正常查看之前的订单,并进行新的下单操作。