老板要搞微服务,只能硬着头皮上了...
微服务越来越火。很多互联网公司,甚至一些传统行业的系统都采用了微服务架构。
图片来自 Pexels
体会到微服务带来好处的同时,很多公司也明显感受到微服务化带来的一系列让人头疼的问题。
本文是笔者对自己多年微服务化经历的总结。如果你正准备做微服务转型,或者在微服务化过程中遇到了困难。此文很可能会帮到你!
正文开始前,为了让各位读友更好的理解本文内容,先花两分钟了解一下微服务的优缺点。
聊起微服务,很多朋友都了解微服务带来的好处,罗列几点:
模块化,降低耦合。将单体应用按业务模块拆分成多个服务,如果某个功能需要改动,大多数情况,我们只需要弄清楚并改动对应的服务即可。
只改动一小部分就能满足要求,降低了其他业务模块受影响的可能性。从而降低了业务模块间的耦合性。
屏蔽与自身业务无关技术细节。例如,很多业务需要查询用户信息,在单体应用的情况下,所有业务场景都通过 DAO 去查询用户信息,随着业务发展,并发量增加,用户信息需要加缓存。
这样所有业务场景都需要关注缓存,微服务化之后,缓存由各自服务维护,其他服务调用相关服务即可,不需要关注类似的缓存问题。
数据隔离,避免不同业务模块间的数据耦合。不同的服务对应不同数据库表,服务之间通过服务调用的方式来获取数据。
业务边界清晰,代码边界清晰。单体架构中不同的业务,代码耦合严重,随着业务量增长,业务复杂后,一个小功能点的修改就可能影响到其他业务点,开发质量不可控,测试需要回归,成本持续提高。
显著减少代码冲突。在单体应用中,很多人在同一个工程上开发,会有大量的代码冲突问题。微服务化后,按业务模块拆分成多个服务,每个服务由专人负责,有效减少代码冲突问题。
可复用,显著减少代码拷贝现象。
微服务系统稳定性问题。微服务化后服务数量大幅增加,一个服务故障就可能引发大面积系统故障。比如服务雪崩,连锁故障。当一个服务故障后,依赖他的服务受到牵连也发生故障。
服务调用关系错综复杂,链路过长,问题难定位。微服务化后,服务数量剧增,大量的服务管理起来会变的更加复杂。由于调用链路变长,定位问题也会更加困难。
数据一致性问题。微服务化后单体系统被拆分成多个服务,各服务访问自己的数据库。而我们的一次请求操作很可能要跨多个服务,同时要操作多个数据库的数据,我们发现以前用的数据库事务不好用了。跨服务的数据一致性和数据完整性问题也就随之而来了。
微服务化过程中,用户无感知数据库拆分、数据迁移的挑战。
如何保障微服务系统稳定性?
微服务化后,服务变多,调用链路变长,如果一个调用链上某个服务节点出问题,很可能引发整个调用链路崩溃,也就是所谓的雪崩效应。
在服务间加熔断:解决服务间纵向连锁故障问题。比如在 A 服务加熔断,当 B 故障时,开启熔断,A 调用 B 的请求不再发送到 B,直接快速返回。这样就避免了线程等待的问题。
当然快速返回什么,Fallback 方案是什么,也需要根据具体场景,比如返回默认值或者调用其他备用服务接口。
服务内(JVM 内)线程隔离:解决横向线程池污染的问题。为了避免因为一个方法出问题导致线程等待最终引发线程资源耗尽的问题,我们可以对 Tomcat,Dubbo 等的线程池分成多个小线程组,每个线程组服务于不同的类或方法。
一个方法出问题,只影响自己不影响其他方法和类。常用开源熔断隔离组件:Hystrix,Resilience4j。
应对突发流量,避免系统被压垮(全局限流和 IP 限流)
防刷,防止机器人脚本等频繁调用服务(userID 限流和 IP 限流)
主要有如下三点:
部署隔离:我们经常会遇到秒杀业务和日常业务依赖同一个服务,以及 C 端服务和内部运营系统依赖同一个服务的情况,比如说都依赖订单服务。
而秒杀系统的瞬间访问量很高,可能会对服务带来巨大的压力,甚至压垮服务。内部运营系统也经常有批量数据导出的操作,同样会给服务带来一定的压力。
这些都是不稳定因素。所以我们可以将这些共同依赖的服务分组部署,不同的分组服务于不同的业务,避免相互干扰。
数据隔离:极端情况下还需要缓存隔离,数据库隔离。以秒杀为例,库存和订单的缓存(Redis)和数据库需要单独部署!
数据隔离后,秒杀订单和日常订单不在相同的数据库,之后的订单查询怎么展示?可以采用相应的数据同步策略。
比如,在创建秒杀订单后发消息到消息队列,日常订单服务收到消息后将订单写入日常订单库。注意,要考虑数据的一致性,可以使用事务型消息。
业务隔离:还是以秒杀为例。从业务上把秒杀和日常的售卖区分开来,把秒杀做为营销活动,要参与秒杀的商品需要提前报名参加活动,这样我们就能提前知道哪些商家哪些商品要参与秒杀。
可以根据提报的商品提前生成商品详情静态页面并上传到 CDN 预热,提报的商品库存也需要提前预热,可以将商品库存在活动开始前预热到 Redis,避免秒杀开始后大量访问穿透到数据库。
关于服务降级
因为是降级方案,可以适当降低用户体验,所以我们保证数据最终一致即可。流程如下图:
对于调用方 A:请求在 A 直接快速返回,快速失败,不再发送到 B。 避免因为 B 故障,导致 A 的请求线程持续等待,进而导致线程池线程和 CPU 资源耗尽,最终导致 A 无法响应,甚至整条调用链故障。
对于被调用方 B:熔断后,请求被 A 拦截,不再发送到 B,B 压力得到缓解,避免了仍旧存活的 B 被压垮,B 得到了保护。
下图为商品服务熔断关闭和开启的对比图:
因为促销活动流量来自于用户,用户的请求会先经过网关层再到后端服务,所以网关层是最合适的限流位置,如下图:
另外,考虑到用户体验问题,我们还需要相应的限流页面。当某些用户的请求被限流拦截后,把限流页面返回给用户。页面如下图:
微服务架构下数据一致性问题
流程图如下:
修改订单状态为“已支付”
扣减库存
扣减优惠券
通知 WMS(仓储管理系统)捡货出库(异步消息)
下面是伪代码,不同公司的产品逻辑会有差异,相关代码逻辑也可能会有不同,大家不用纠结代码逻辑正确性。
public void makePayment() {
orderService.updateStatus(OrderStatus.Payed); //订单服务更新订单为已支付状态
inventoryService.decrStock(); //库存服务扣减库存
couponService.updateStatus(couponStatus.Used); //卡券服务更新优惠券为已使用状态
}
Try(预留冻结相关业务资源,设置临时状态,为下个阶段做准备)
Confirm 或者 Cancel(Confirm:对资源进行最终操作,Cancel:取消资源)
更新订单状态:此时因为还没真正完成整个流程,订单状态不能直接改成已支付状态。可以加一个临时状态 Paying,表明订单正在支付中,支付结果暂时还不清楚!
冻结库存:假设现在可销售库存 stock 是 10,这单扣减 1 个库存,别直接把库存减掉,而是在表中加一个冻结字段 locked_stock,locked_stock 加 1,再给 stock 减 1,这样就相当于冻结了 1 个库存。两个操作放在一个本地事务里。
更新优惠券状态:优惠券加一个临时状态 Inuse,表明优惠券正在使用中,具体有没有正常被使用暂时还不清楚!
先将订单状态从 Paying 改为已支付 Payed,订单状态也完成了。
再将冻结的库存恢复 locked_stock 减 1,stock 第一阶段已经减掉 1 是 9 了,到此扣减库存就真正完成了。
再将优惠券状态从 Inuse 改为 Used,表明优惠券已经被正常使用。
先将订单状态从 Paying 恢复为待支付 UnPayed。
再将冻结的库存还回到可销售库存中,stock 加 1 恢复成 10,locked_stock 减 1,可以放在一个本地事务完成。
再将优惠券状态从 Inuse 恢复为未使用 Unused。
基于 Hmily 框架的代码:
//订单服务
public class OrderService{
//tcc接口
@Hmily(confirmMethod = "confirmOrderStatus", cancelMethod = "cancelOrderStatus")
public void makePayment() {
更新订单状态为支付中
冻结库存,rpc调用
优惠券状态改为使用中,rpc调用
}
public void confirmOrderStatus() {
更新订单状态为已支付
}
public void cancelOrderStatus() {
恢复订单状态为待支付
}
}
//库存服务
public class InventoryService {
//tcc接口
@Hmily(confirmMethod = "confirmDecr", cancelMethod = "cancelDecr")
public void lockStock() {
//防悬挂处理(下面有说明)
if (分支事务记录表没有二阶段执行记录)
冻结库存
else
return;
}
public void confirmDecr() {
确认扣减库存
}
public void cancelDecr() {
释放冻结的库存
}
}
//卡券服务
public class CouponService {
//tcc接口
@Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
public void handleCoupon() {
//防悬挂处理(下面有说明)
if (分支事务记录表没有二阶段执行记录)
优惠券状态更新为临时状态Inuse
else
return;
}
public void confirm() {
优惠券状态改为Used
}
public void cancel() {
优惠券状态恢复为Unused
}
}
还是以上面的电商下单流程为例:
上图,下单流程最后一步,通知 WMS 捡货出库,是异步消息走消息队列。
public void makePayment() {
orderService.updateStatus(OrderStatus.Payed); //订单服务更新订单为已支付状态
inventoryService.decrStock(); //库存服务扣减库存
couponService.updateStatus(couponStatus.Used); //卡券服务更新优惠券为已使用状态
发送MQ消息捡货出库; //发送消息通知WMS捡货出库
}
发送半消息(所有事务型消息都要经历确认过程,从而确定最终提交或回滚(抛弃消息),未被确认的消息称为“半消息”或者“预备消息”,“待确认消息”)。
半消息发送成功并响应给发送方。
执行本地事务,根据本地事务执行结果,发送提交或回滚的确认消息。
如果确认消息丢失(网络问题或者生产者故障等问题),MQ 向发送方回查执行结果。
根据上一步骤回查结果,确定提交或者回滚(抛弃消息)。
结合上面几个同步调用 Hmily 完整代码如下:
//TransactionListener是rocketmq接口用于回调执行本地事务和状态回查
public class TransactionListenerImpl implements TransactionListener {
//执行本地事务
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
记录orderID,消息状态键值对到共享map中,以备MQ回查消息状态使用;
return LocalTransactionState.COMMIT_MESSAGE;
}
//回查发送者状态
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
String status = 从共享map中取出orderID对应的消息状态;
if("commit".equals(status))
return LocalTransactionState.COMMIT_MESSAGE;
else if("rollback".equals(status))
return LocalTransactionState.ROLLBACK_MESSAGE;
else
return LocalTransactionState.UNKNOW;
}
}
//订单服务
public class OrderService{
//tcc接口
@Hmily(confirmMethod = "confirmOrderStatus", cancelMethod = "cancelOrderStatus")
public void makePayment() {
1,更新订单状态为支付中
2,冻结库存,rpc调用
3,优惠券状态改为使用中,rpc调用
4,发送半消息(待确认消息)通知WMS捡货出库 //创建producer时这册TransactionListenerImpl
}
public void confirmOrderStatus() {
更新订单状态为已支付
}
public void cancelOrderStatus() {
恢复订单状态为待支付
}
}
//库存服务
public class InventoryService {
//tcc接口
@Hmily(confirmMethod = "confirmDecr", cancelMethod = "cancelDecr")
public void lockStock() {
//防悬挂处理
if (分支事务记录表没有二阶段执行记录)
冻结库存
else
return;
}
public void confirmDecr() {
确认扣减库存
}
public void cancelDecr() {
释放冻结的库存
}
}
//卡券服务
public class CouponService {
//tcc接口
@Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
public void handleCoupon() {
//防悬挂处理
if (分支事务记录表没有二阶段执行记录)
优惠券状态更新为临时状态Inuse
else
return;
}
public void confirm() {
优惠券状态改为Used
}
public void cancel() {
优惠券状态恢复为Unused
}
}
微服务化过程,无感知数据迁移
第一,保证迁移后数据准确不丢失,即每条记录准确而且不丢失记录。
第二,不影响用户体验(尤其是访问量高的C端业务需要不停机平滑迁移)。
第三,保证迁移后的性能和稳定性。
业务重要程度一般或者是内部系统,数据结构不变,这种场景下可以采用挂从库,数据同步完找个访问低谷时间段,停止服务,然后将从库切成主库,再启动服务。简单省时,不过需要停服避免切主库过程数据丢失。
重要业务,并发高,数据结构改变。这种场景一般需要不停机平滑迁移。下面就重点介绍这部分经历。
①开启双写,新老库同时写入(涉及到代码改动)。注意:任何对数据库的增删改都要双写。
对于更新操作,如果新库没有相关记录,先从老库查出记录更新后写入数据库;为了保证写入性能,老库写完后,可以采用消息队列异步写入新库。
同时写两个库,不在一个本地事务,有可能出现数据不一致的情况,这样就需要一定的补偿机制来保证两个库数据最终一致。下一篇文章会分享最终一致性解决方案
②将某时间戳之前的老数据迁移到新库(需要脚本程序做老数据迁移,因为数据结构变化比较大的话,从数据库层面做数据迁移就很困难了)。
注意两点:
时间戳一定要选择开启双写后的时间点,避免部分老数据被漏掉。
迁移过程遇到记录冲突直接忽略(因为第一步有更新操作,直接把记录拉到了新库);迁移过程一定要记录日志,尤其是错误日志。
③第二步完成后,我们还需要通过脚本程序检验数据,看新库数据是否准确以及有没有漏掉的数据。
④数据校验没问题后,开启双读,起初新库给少部分流量,新老两库同时读取,由于时间延时问题,新老库数据可能有些不一致,所以新库读不到需要再读一遍老库。
逐步将读流量切到新库,相当于灰度上线的过程。遇到问题可以及时把流量切回老库。
⑤读流量全部切到新库后,关闭老库写入(可以在代码里加上可热配开关),只写新库。
⑥迁移完成,后续可以去掉双写双读相关无用代码。
全链路 APM 监控
请求调用栈(Call Stack)监控:
点击数据库图表还可以看详细 SQL 语句,如下图:
如果发生错误,会显示为红色,错误原因也会直接显示出来。如下图:
请求 Server Map:
JVM 监控:
作者:二马读书
简介:曾任职于阿里巴巴,每日优鲜等互联网公司,任技术总监,15 年电商互联网经历。
编辑:陶家龙
出处:转载自微信公众号架构师进阶之路(ID:ermadushu)
精彩文章推荐:
关注公众号:拾黑(shiheibook)了解更多
[广告]赞助链接:
四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/
随时掌握互联网精彩
- 1 习近平向全军老同志祝贺新春 7995672
- 2 官宣!央视春晚主持人确定 7975803
- 3 美国网友已经准备在屋前屋后种菜 7887705
- 4 老集市人气旺焕发新活力 7788945
- 5 爸爸一句含金量不高孩子气哭撕奖状 7655392
- 6 这届年轻人开始“整顿”年味了 7583331
- 7 国家一级演员邬丽因病去世 7412412
- 8 周受资代表TikTok感谢美国用户 7348034
- 9 杨子说自己净身出户 7214161
- 10 公安厅副厅长61岁把自己送进监狱 7145607