多活容灾MSHA(Multi-Site High Availability)是一个云原生的多活容灾架构解决⽅案。本文介绍一个由多个微服务实现的完整业务应用利用MSHA产品改造为异地多活架构的过程。
前提条件
- 创建2个集群,请参见创建Kubernetes托管版集群。
- 开通阿里云应用配置管理ACM。
- 开通阿里云数据库MySQL版的RDS或DRDS,请参见RDS MySQL数据库。
- 可选:开通阿里云云解析DNS,请参见云解析DNS。
开通产品
- 公测阶段,您需要申请开通并配置MSHA。
- 确定您需要的多活架构类型(仅异地、仅同城、异地+同城),本文示例以仅异地为例。
- 确定多活业务类型,例如电商业务可以申请两种业务类型:导购和交易。若您无特殊需求,则不需要处理,MSHA会为您生成默认业务类型。
改造架构
假设这个业务由以下微服务共同实现,服务发现依赖于K8s实现,服务间调用利用Feign实现。
- frontend,一个传统的MVC服务。负责和用户交互。
- cartservice,购物车服务。记录用户的购物车数据,自建Redis存储。
- productservice,产品服务。存储商品信息,包含商品详情、库存等,自建MySQL存储。
- checkoutservice,下单服务。从购物车拉取商品信息,并从产品服务检验库存,完成下单,自建MySQL存储。
为了演示方便,本文示例仅将下单服务进行单元化改造,改造完成后,下单服务会有单元保护、跨单元订单操作等功能,为此需要将自建MySQL换成阿里云RDS,如下图所示。
主要原因是自建MySQL非标准数据库,MSHA无法管控,后续阿里云会提供一系列的标准,并支持符合这些标准的自建MySQL。
控制台接入
- 配置命名空间。
- 配置接入层。
- 配置异地数据层。
改造数据面
- 服务层
由于服务层无多活逻辑,所以您仅需做多活参数透传,也就是
routerId
和unitType
(如果仅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 ); } }
- 数据层
-
执行以下命令,数据层引入msha-client。
<dependency> <groupId>com.aliyun.unit.router</groupId> <artifactId>msha-sdk-client</artifactId> <version>1.0.4-SNAPSHOT</version> </dependency>
-
启动参数指定
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
- 数据层操作数据库时,调用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做了单元化,所以切流后用户仍可以正常查看之前的订单,并进行新的下单操作。