2021-11-15
字节跳动,阿里巴巴,美团架构设计细则文章目录字节跳动,阿里巴巴,美团架构设计细则前言一、分布式技术栈分布式全局ID策略二、接口幂等性原则三、缓存管理和监控四、异步处理五、流量削峰与资源加锁六、分库分表与事务七、消息系统组件选型微服务架构数据存储八、自动化部署蓝绿部署滚动发布Jenkins集成Docker容器K8S容器管理总结前言现在字节跳动,阿里巴巴,美团架构设计追求集约化,我们来了解一下功能模块
字节跳动,阿里巴巴,美团架构设计细则
文章目录
前言
现在字节跳动,阿里巴巴,美团架构设计追求集约化,集约化就是要凸显各要素的质量,在现有环境中,我们要处理的不是一个框架的问题,因为框架搭建好后,我们一般不会修改整体的框架,我们针对的只会是其中的一块要素,也就是我说的集约化管理,每一个内容都需要考虑完善,争取每一个模块都能达到最好的设计思路和要求,这样在处理问题的时候我们才能够有针对性。我们来了解一下功能模块集约化的架构。
一、分布式技术栈
分布式锁
悲观锁
所有请求的线程必须在获取锁之后,才能执行数据库操作,并且基于序列化的模式,没有获取锁的线程处于等待状态,并且设定重试机制,在单位时间后再次尝试获取锁,或者直接返回。
SETNX:加锁的思路是,如果key不存在,将key设置为value如果key已存在,则 SETNX 不做任何动作。并且可以给key设置过期时间,过期后其他线程可以继续尝试锁获取机制。
Redis锁实现
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import javax.annotation.Resource;
@Component
public class RedisLock {
@Resource
private Jedis jedis ;
/**
* 获取锁
*/
public boolean getLock (String key,String value,long expire){
try {
String result = jedis.set( key, value, "nx", "ex", expire);
return result != null;
} catch (Exception e){
e.printStackTrace();
}finally {
if (jedis != null) jedis.close();
}
return false ;
}
/**
* 释放锁
*/
public boolean unLock (String key){
try {
Long result = jedis.del(key);
return result > 0 ;
} catch (Exception e){
e.printStackTrace();
}finally {
if (jedis != null) jedis.close();
}
return false ;
}
}
配置文件
@Configuration
public class RedisConfig {
@Bean
public JedisPoolConfig jedisPoolConfig (){
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig() ;
jedisPoolConfig.setMaxIdle(8);
jedisPoolConfig.setMaxTotal(20);
return jedisPoolConfig ;
}
@Bean
public JedisPool jedisPool (@Autowired JedisPoolConfig jedisPoolConfig){
return new JedisPool(jedisPoolConfig,"127.0.0.1",6379) ;
}
@Bean
public Jedis jedis (@Autowired JedisPool jedisPool){
return jedisPool.getResource() ;
}
}
锁版本问题
CREATE TABLE `dl_data_lock` (
`id` INT (11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`inventory` INT (11) DEFAULT '0' COMMENT '库存量',
`lock_value` INT (11) NOT NULL DEFAULT '0' COMMENT '锁版本',
PRIMARY KEY (`id`)
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '锁机制表';
<update id="updateByLock">
UPDATE dl_data_lock SET inventory=inventory-1,lock_value=#{lockVersion}
WHERE id=#{id} AND lock_value <#{lockVersion}
</update>
从上面可以看出,锁版本是非常重要的内容,lock_value就是记录锁版本,作为控制数据更新的条件。假设线程01获取锁版本1,如果没有执行,线程02获取锁版本2,执行之后,通过锁版本的比较,线程01的锁版本过低,数据更新就会失败。
乐观锁
乐观锁大多是基于数据记录来控制,在更新数据库的时候,基于前置的查询条件判断,如果查询出来的数据没有被修改,则更新操作成功,如果前置的查询结果作为更新的条件不成立,则数据写失败。
@Override
public Boolean updateByInventory(Integer id) {
DataLockEntity dataLockEntity = dataLockMapper.getById(id);
if (dataLockEntity != null){
return dataLockMapper.updateByInventory(id,dataLockEntity.getInventory())>0 ;
}
return false ;
}
一般的操作是,我们会在数据库中添加一个version字段,在更新语句后后端我们也会添加一个version字段,如果更新前,我们发现两个version字段是相同的,那我们就更新数据,反之,我们不更新数据,这样就能维护数据一致性。
分布式监控
分布式监控是一个分布式系统重点考虑的问题,显然分布式系统是故障多发地。
应用服务
应用层为开发的业务逻辑服务,也是最容易突发问题的一个层面,当在一家公司待久了,因为开发过多个业务线,就会感觉自己不是开发,是个打杂的,每天都要分出大量时间处理各种问题。应用层监控涉及下面几个核心模块:
请求流量
任何服务,高并发的流量都会暴露各种服务问题,尤其核心接口的流量更是监控的重点。
服务链路
一次请求发生问题,快速判断问题所在的服务,或者哪些服务之间,这对快速处理问题是至关重要的。
日志体系
核心接口日志记录也是必备的功能,通常情况下基于日志体系的分析结果,可以明确系统的异常点,重点优化。
软件服务
为了解决分布式系统的各种复杂业务场景,通常会引入各种中间软件来做支撑,例如必备的数据库,缓存,消息MQ等,通常这些中间件都会有自带的监控管理端口。
数据库:较多使用Druid监控分析;
消息队列:常用RocketMQ和控制台;
Redis缓存:提供命令获取相关监控数据;
还有一些公司甚至直接在中间件层开发一套管理运维和监控的聚合平台,这样更容易从整体上分析问题。
硬件服务
硬件层面,运维最关注的三大核心内容:CPU、内存、网络。底层硬件资源爆发的故障,来自上层的应用服务或者中间件服务触发的可能性偏高。
硬件层面的监控有许多成熟的框架,例如zabbix,grafana等,当然这些组件功能很丰富,不仅仅在硬件层应用。
雪崩效应
有些故障导致大面积服务瘫痪,也称为雪崩效应,可能故障源没有快速处理,也没有熔断机制,导致整个服务链路全部垮掉,这是常见的问题,所以在处理故障时,要学会基于全栈监控信息,全局关联分析核心故障点,快速切断单点服务的故障,保证整个系统的可用性。
分布式全局ID策略
雪花算法生成分布式ID,Twitter公司开源的分布式ID生成算法策略,生成的ID遵循时间的顺序。
1为位标识,始终为0,不可用;
41位时间截,存储时间截的差值(当前时间截-开始时间截);
10位的机器标识,10位的长度最多支持部署1024个节点;
12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒产生4096个ID序号;
SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高。
/**
* 雪花算法ID生成
*/
public class SnowIdWorkerUtil {
// 开始时间截 (2020-01-02)
private final long timeToCut = 1577894400000L;
// 机器ID所占的位数
private final long workerIdBits = 2L;
// 数据标识ID所占的位数
private final long dataCenterIdBits = 8L;
// 支持的最大机器ID,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 支持的最大数据标识ID,结果是31
private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
// 序列在ID中占的位数
private final long sequenceBits = 12L;
// 机器ID向左移12位
private final long workerIdShift = sequenceBits;
// 数据标识ID向左移17位(12+5)
private final long dataCenterIdShift = sequenceBits + workerIdBits;
// 时间截向左移22位(5+5+12)
private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
// 生成序列的掩码
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
// 工作机器ID(0~31)
private long workerId;
// 数据中心ID(0~31)
private long dataCenterId;
// 毫秒内序列(0~4095)
private long sequence = 0L;
// 上次生成ID的时间截
private long lastTimestamp = -1L;
/**
* 构造函数
* @param workerId 工作ID (0~31)
* @param dataCenterId 数据中心ID (0~31)
*/
public SnowIdWorkerUtil (long workerId, long dataCenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException("workerId 不符合条件");
}
if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
throw new IllegalArgumentException("dataCenterId 不符合条件");
}
this.workerId = workerId;
this.dataCenterId = dataCenterId;
}
public synchronized String nextIdVar(){
return String.valueOf(nextId());
}
/**
* 线程安全,获得下一个ID
*/
private synchronized long nextId() {
long timestamp = timeGen();
// 如果当前时间小于上一次ID生成的时间戳,抛出异常
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format(
"时间顺序异常,时间差(上次时间-现在)=%d",
lastTimestamp - timestamp));
}
// 如果是同一时间生成的,则进行毫秒内序列
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
//毫秒内序列溢出
if (sequence == 0) {
//阻塞到下一个毫秒,获得新的时间戳
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 时间戳改变,毫秒内序列重置
sequence = 0L;
}
// 上次生成ID的时间截
lastTimestamp = timestamp;
// 移位并通过或运算拼到一起组成64位的ID
return ((timestamp - timeToCut) << timestampLeftShift)
| (dataCenterId << dataCenterIdShift)
| (workerId << workerIdShift) | sequence;
}
/**
* 阻塞,获得新的时间戳
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回当前时间节点
*/
private long timeGen() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
// 参数在实际业务下需要配置管理
SnowIdWorkerUtil idWorker = new SnowIdWorkerUtil(1, 1);
for (int i = 0; i < 100; i++) {
String id = idWorker.nextIdVar();
System.out.println(id+" "+id.length()+"位");
}
}
}
二、接口幂等性原则
为什么会出现幂等问题:
- 网络波动, 可能会引起重复请求
- 用户重复操作,用户在操作时候可能会无意触发多次下单交易,甚至没有响应而有意触发多次交易应用
- 使用了失效或超时重试机制(Nginx重试、RPC重试或业务层重试等) 页面重复刷新
- 使用浏览器后退按钮重复之前的操作,导致重复提交表单
- 使用浏览器历史记录重复提交表单
- 浏览器重复的HTTP请求
- 定时任务重复执行
- 用户双击提交按钮
编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。就是说,一次和多次请求某一个资源会产生同样的作用影响。
公众经常强调http接口幂等性,我们一起来分析一下:
- GET:用于获取资源,不应有副作用,所以是幂等的;
- POST:用于创建资源,重复提交POST请求可能产生两个不同的资源,有副作用不满足幂等性;
- PUT:用于更新操作,重复提交PUT请求只会对其URL中指定的资源有副作用,满足幂等性;
- DELETE:用于删除资源,有副作用,但它应该满足幂等性;
- HEAD:和GET本质是一样的,但HEAD不含有呈现数据,仅是HTTP头信息,没有副作用,满足幂等性;
- OPTIONS:用于获取当前URL所支持的请求方法,满足幂等性;
满足幂等也就意味着多次请求,返回的结构是一致的,不会存在区别。首先我们要处理接口重复提交问题。接口重复提交就不可避免会出现多次请求的情况。
接口重复提交
在实际情况中,接口如果处理时间过长,用户可能会点击多次提交按钮,导致数据重复。常见的一个解决方案:在表单提交中隐藏一个token_id参数,一起提交到接口服务中,数据库存储订单和关联的tokenId,如果多次提交,直接返回页面提示信息即可。
案例:订单查询
@Service
public class OrderServiceImpl implements OrderService {
@Override
public Boolean queryToken(OrderState orderState) {
Map<String,Object> paramMap = new HashMap<>() ;
paramMap.put("order_id",orderState.getOrderId());
paramMap.put("token_id",orderState.getTokenId());
List<OrderState> orderStateList = orderStateMapper.selectByMap(paramMap);
return orderStateList.size() > 0 ;
}
}
测试
@RestController
public class OrderController {
@Resource
private OrderService orderService ;
@PostMapping("/repeatSub")
public String repeatSub (OrderState orderState){
boolean flag = orderService.queryToken(orderState) ;
if (flag){
return "请勿重复提交订单" ;
}
return "success" ;
}
}
订单支付业务
- 客户端发起订单支付请求 ;
- 支付前系统本地相关业务处理 ;
- 请求第三方支付服务执行扣款;
- 第三方支付返回处理结果;
- 本地服务基于支付结果响应客户端;
当上述流程的支付请求有明确结果的时候:失败或成功,这样业务流程都好处理,但是例如支付场景如果请求超时,如何判断服务的结果状态:客户端请求超时,本地服务超时,请求支付超时,支付回调超时,客户端响应超时等等。
表结构设计
CREATE TABLE `dp_order_state` (
`order_id` BIGINT (20) NOT NULL AUTO_INCREMENT COMMENT '订单id',
`token_id` VARCHAR (50) DEFAULT NULL COMMENT '防重复提交',
`state` INT (1) DEFAULT '1' COMMENT '1创建订单,2本地业务,3支付业务',
PRIMARY KEY (`order_id`)
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '订单状态表';
CREATE TABLE `dp_state_record` (
`id` INT (11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_id` BIGINT (20) NOT NULL COMMENT '订单id',
`state_dec` VARCHAR (50) DEFAULT NULL COMMENT '状态描述',
PRIMARY KEY (`id`)
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '状态记录表';
模拟业务流程
@Service
public class OrderServiceImpl implements OrderService {
@Resource
private OrderStateMapper orderStateMapper ;
@Resource
private StateRecordMapper stateRecordMapper ;
@Override
public OrderState queryOrder(OrderState orderState) {
Map<String,Object> paramMap = new HashMap<>() ;
paramMap.put("order_id",orderState.getOrderId());
List<OrderState> orderStateList = orderStateMapper.selectByMap(paramMap);
if (orderStateList != null && orderStateList.size()>0){
return orderStateList.get(0) ;
}
return null ;
}
@Override
public boolean createOrder(OrderState orderState) {
int saveRes = orderStateMapper.insert(orderState);
if (saveRes > 0){
saveStateRecord(orderState.getOrderId(),"订单创建成功");
}
return saveRes > 0 ;
}
@Override
public boolean localBiz(OrderState orderState) {
orderState.setState(2);
int updateRes = orderStateMapper.updateState(orderState) ;
if (updateRes > 0){
saveStateRecord(orderState.getOrderId(),"本地业务成功");
}
return updateRes > 0;
}
@Override
public boolean paymentBiz(OrderState orderState) {
orderState.setState(3);
int updateRes = orderStateMapper.updateState(orderState) ;
if (updateRes > 0){
saveStateRecord(orderState.getOrderId(),"支付业务成功");
}
return updateRes > 0;
}
private void saveStateRecord (Long orderId,String stateDec){
StateRecord stateRecord = new StateRecord() ;
stateRecord.setOrderId(orderId);
stateRecord.setStateDec(stateDec);
stateRecordMapper.insert(stateRecord) ;
}
}
测试
@Api(value = "OrderController")
@RestController
public class OrderController {
@Resource
private OrderService orderService ;
@PostMapping("/submitOrder")
public String submitOrder (OrderState orderState){
OrderState orderState01 = orderService.queryOrder(orderState) ;
if (orderState01 == null){
// 正常业务流程
orderService.createOrder(orderState) ;
orderService.localBiz(orderState) ;
orderService.paymentBiz(orderState) ;
} else {
switch (orderState01.getState()){
case 1:
// 订单创建成功:后推执行本地和支付业务
orderService.localBiz(orderState01) ;
orderService.paymentBiz(orderState01) ;
break ;
case 2:
// 订单本地业务成功:后推执行支付业务
orderService.paymentBiz(orderState01) ;
break ;
default:
break ;
}
}
return "success" ;
}
}
当然上面只是很小的一个业务模块,真正的电商业务模块肯定比这要复杂的多。
三、缓存管理和监控
缓存模式cache-aside模式如下:
缓存命中:直接查询缓存且命中,返回数据;
缓存加载:查询缓存未命中,从数据库中查询数据,获取数据后并加载到缓存;
缓存失效:数据更新写到数据库,操作成功后,让缓存失效,查询时候再重新加载;
缓存穿透:查询数据库不存在的对象,也就不存在缓存层的命中;
缓存击穿:热点key在失效的瞬间,高并发查询这个key,击穿缓存,直接请求数据库;
缓存雪崩:缓存Key大批量到过期时间,导致数据库压力过大;
@Service
public class KeyValueServiceImpl extends ServiceImpl<KeyValueMapper, KeyValueEntity> implements KeyValueService {
@Resource
private RedisService redisService ;
@Override
public KeyValueEntity select(Integer id) {
// 查询缓存
String redisKey = RedisKeyUtil.getObectKey(id) ;
String value = redisService.get(redisKey) ;
if (!StringUtils.isEmpty(value) && !value.equals("null")){
return JSON.parseObject(value,KeyValueEntity.class);
}
// 查询库
KeyValueEntity keyValueEntity = this.getById(id) ;
if (keyValueEntity != null){
// 缓存写入
redisService.set(redisKey,JSON.toJSONString(keyValueEntity)) ;
}
// 返回值
return keyValueEntity ;
}
@Override
public boolean update(KeyValueEntity keyValueEntity) {
// 更新数据
boolean updateFlag = this.updateById(keyValueEntity) ;
// 清除缓存
if (updateFlag){
redisService.delete(RedisKeyUtil.getObectKey(keyValueEntity.getId()));
}
return updateFlag ;
}
}
数据一致问题
方案一说明:
数据库更新写入数据成功;
准备一个先进先出模式的消息队列;
把更新的数据包装为一个消息放入队列;
基于消息消费服务更新Redis缓存;
分析:消息队列的稳定和可靠性,操作层面数据库和缓存层解耦。
方案二说明:
提供一个数据库Binlog订阅服务,并解析修改日志;
服务获取修改数据,并向Redis服务发送消息;
Redis数据进行修改,类似MySQL的主从同步机制;
分析:系统架构层面多出一个服务,且需要解析MySQL日志,操作难度较大,但流程上更为合理。
Redis服务监控
@RestController
public class MonitorController {
@Resource
private RedisService redisService ;
private static final String[] monitorParam = new String[]{"memory","server","clients","stats","cpu"} ;
@GetMapping("/monitor")
public List<MonitorEntity> monitor (){
List<MonitorEntity> monitorEntityList = new ArrayList<>() ;
for (String param:monitorParam){
Properties properties = redisService.info(param) ;
MonitorEntity monitorEntity = new MonitorEntity () ;
monitorEntity.setMonitorParam(param);
monitorEntity.setProperties(properties);
monitorEntityList.add(monitorEntity);
}
return monitorEntityList ;
}
}
memory:内存消耗相关信息
server:有关Redis服务器的常规信息
clients:客户端连接部分
stats:一般统计
cpu:CPU消耗统计信息
四、异步处理
异步
处理不用阻塞当前线程来等待处理完成,而是允许后续操作,直至其它线程将处理完成,并回调通知此线程。必须强调一个基础逻辑,异步是一种设计理念,异步操作不等于多线程,MQ中间件,或者消息广播,这些是可以实现异步处理的方式。
- 异步可以解耦业务间的流程关联,降低耦合度;
- 降低接口响应时间,例如用户注册,异步生成相关信息表;
- 异步可以提高系统性能,提升吞吐量;
- 流量削峰即把请求先承接下来,然后在异步处理;
- 异步用在不同服务间,可以隔离服务,避免雪崩;
异步处理的实现方式有很多种,常见多线程,消息中间件,发布订阅的广播模式,其根据逻辑在于先把请求承接下来,放入容器中,在从容器中把请求取出,统一调度处理。而且一定要监控任务是否产生积压过度情况,任务如果积压到雪崩之势的地步。
异步处理模式有以下三种:
- 基于接口异步响应,常用在第三方对接流程;
- 基于消息生产和消费模式,解耦复杂流程;
- 基于发布和订阅的广播模式,常见系统通知
接口响应异步模式
1本地服务发起请求,调用第三方服务接口;
2请求包含业务参数,和成功或失败的回调地址;
3第三方服务实时响应流水号,作为该调用的标识;
4之后第三方服务处理请求,得到最终处理结果;
5如果处理成功,回调本地服务的成功通知接口;如果处理失败,回调本地服务的失败通知接口;
基础接口
@RestController
public class ReqAsyncWeb {
private static final Logger LOGGER = LoggerFactory.getLogger(ReqAsyncWeb.class);
@Resource
private ReqAsyncService reqAsyncService ;
// 本地交易接口
@GetMapping("/tradeBegin")
public String tradeBegin (){
String sign = reqAsyncService.tradeBegin("TradeClient");
return sign ;
}
// 交易成功通知接口
@GetMapping("/tradeSucNotify")
public String tradeSucNotify (@RequestParam("param") String param){
LOGGER.info("tradeSucNotify param={"+ param +"}");
return "success" ;
}
// 交易失败通知接口
@GetMapping("/tradeFailNotify")
public String tradeFailNotify (@RequestParam("param") String param){
LOGGER.info("tradeFailNotify param={"+ param +"}");
return "success" ;
}
// 第三方交易接口
@GetMapping("/respTrade")
public String respTrade (@RequestParam("param") String param){
LOGGER.info("respTrade param={"+ param +"}");
reqAsyncService.respTrade(param);
return "NO20200520" ;
}
}
第三方接口
@Service
public class ReqAsyncServiceImpl implements ReqAsyncService {
private static final String serverUrl = "http://localhost:8005" ;
@Override
public String tradeBegin(String param) {
String orderNo = HttpUtil.get(serverUrl+"/respTrade?param="+param);
if (StringUtils.isEmpty(orderNo)){
return "Trade..Fail...";
}
return orderNo ;
}
@Override
public void respTrade(String param) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread01 = new Thread(
new RespTask(serverUrl+"/tradeSucNotify?param="+param),"SucNotify");
Thread thread02 = new Thread(
new RespTask(serverUrl+"/tradeFailNotify?param="+param),"FailNotify");
thread01.start();
thread02.start();
}
}
消息生产和消费模式
1消息生成之后,写入Kafka队列 ;
2消息处理方获取消息后,进行流程处理;
3消息在中间件提供的队列中持久化存储 ;
4消息发起方如果挂掉,不影响消息处理 ;
5消费方如果挂掉,不影响消息生成;
消息发送
@Service
public class KafkaAsyncServiceImpl implements KafkaAsyncService {
@Resource
private KafkaTemplate<string, string> kafkaTemplate;
@Override
public void sendMsg(String msg) {
// 这里Topic如果不存在,会自动创建
kafkaTemplate.send("kafka-topic", msg);
}
}
消息消费
@Component
public class KafkaConsumer {
private static Logger LOGGER = LoggerFactory.getLogger(KafkaConsumer.class);
@KafkaListener(topics = "kafka-topic")
public void listenMsg (ConsumerRecord<!--?,String--> record) {
String value = record.value();
LOGGER.info("KafkaConsumer01 ==>>"+value);
}
}
即便存在很多消费方,真正处理消息的只会是一个消费方。
发布订阅异步模式
本模式基于Redis,之所以称为广播模式,该模式更注重通知下发,流程交互性不强。实际开发场景:运维总控系统,更新了某类服务配置,通知消息发送之后,相关业务线上的服务在拉取最新配置,更新到服务中。
发送通知消息
@Service
public class RedisAsyncServiceImpl implements RedisAsyncService {
@Resource
private StringRedisTemplate stringRedisTemplate ;
@Override
public void sendMsg(String topic, String msg) {
stringRedisTemplate.convertAndSend(topic,msg);
}
}
接收通知消息
@Service
public class ReceiverServiceImpl implements ReceiverService {
private static final Logger LOGGER = LoggerFactory.getLogger("ReceiverMsg");
@Override
public void receiverMsg(String msg) {
LOGGER.info("Receiver01 收到消息:msg-{}",msg);
}
}
配置广播模式
@Configuration
public class SubMsgConfig {
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory factory,
MessageListenerAdapter msgListenerAdapter,
MessageListenerAdapter msgListenerAdapter02){
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
//注册多个监听,订阅一个主题,实现消息广播
container.addMessageListener(msgListenerAdapter, new PatternTopic("topic:msg"));
container.addMessageListener(msgListenerAdapter02, new PatternTopic("topic:msg"));
return container;
}
@Bean
MessageListenerAdapter msgListenerAdapter(ReceiverService receiverService){
return new MessageListenerAdapter(receiverService, "receiverMsg");
}
@Bean
MessageListenerAdapter msgListenerAdapter02(ReceiverService02 receiverService02){
return new MessageListenerAdapter(receiverService02, "receiverMsg");
}
@Bean
ReceiverService receiverService(){
return new ReceiverServiceImpl();
}
@Bean
ReceiverService02 receiverService02(){
return new ReceiverServiceImpl02();
}
}
五、事务
首先我们以订单服务为例介绍一下分布式事务的特点:
数据库基于业务特点,进行分库分表;数据库拆分,随之就是业务的服务化(SOA);基于电商业务进行拆分,会出现常见的:订单,用户,库存,物流等一系列的服务,管理不同的业务数据库,在实际的下单支付应用场景下,需要同时操作用户,订单,库存等多个服务,就必须保证数据一致性,下单支付成功,库存必须就需要用到分布式事务。
基础理论
CAP基础理论
Consistency:一致性
单个事务执行更新写操作,操作结束成功返回,在同一时间的其他事务读取的数据完全一致,不存在中间状态。在分布式的系统中描述:用户下单支付,扣款,减库存,生成物流,必须一致。例如限量打折促销中,用户下单后库存没减少,这就导致不一致问题。
Availability:可用性
服务必须一直处于可用的状态,收到用户的请求,服务器必须在有限的时间给出回应,不管结果是处理成功或者处理失败。
Partition tolerance:分区容错
通俗说,在分布式系统中,一个流程里可能出现某个服务出错情况,这是无法绝对避免的,在程序设计上要能容忍这种错误发生。
CP和AP模式
分布式系统很难同时满足一致性、可用性、分区容错性三个特点,在大部分的系统架构中,都会选择CP或者AP模式,即需要抛弃一个特点,说明一点,为何P没有抛弃,对于分布式系统而言,分区容错是该架构模式下的基本原则,不同的SOA服务和数据库是比如会被部署到不同的节点下。所以如何解决C(一致性)和A(可用性)就成分布式系统的最大痛点。为何不能同时满足C和A,这也是基于分布式架构特点看,不同服务直接不能保证通信是100%成功,一旦出现失败情况,一致性和可用性就无法满足。既然强一致性无法保证,那退一步,给处理时间,最后结果保证一致性,也可以,这就涉及到BASE理论。
BASE基础理论
BASE理论是由eBay公司的架构师提出的,主要是对上述的CAP理论中一致性和可用性做的权衡结果,基于CAP定律逐步演化而来,核心思想;即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当策略实现数据的最终一致性。
Basically Available:基本可用
分布式系统在发生故障的时,允许损失部分可用性。例如常见电商清仓甩卖时,为保证主业务可以,一些不重要的服务直接降级提示。
Soft State:软状态
允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种硬状态。
Eventual Consistency:最终一致
强调的数据更新操作,即软状态必须有个时间期限,在经过一段时间的同步之后,最终都能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。时间期限长短取决于延时、负载、数据同步等各种因素。
BASE理论提出是基于大规模高可用可扩展的分布式系统架构,不同于关系型数据库事务特点(ACID)的强一致性模型,通过牺牲强一致性来获取更高的可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。实际的业务场景下事物(ACID)基本特性和BASE理论也是要权衡考虑。
2、柔性事务
遵循BASE理论,利用业务特点,在指定期限内让事务保持最终一致性,柔性事务是一种思想,从根本上看,就是业务模式对于事务过程中不一致性有一定的容忍度,可以留出足够的时间执行事务最终一致的方法。
3、PAXOS算法
Paxos算法一种保障分布式系统最终一致性的共识算法,利用的是选举策略,少数服从多数的思想。PAXOS不要求对所有节点做实时同步,实质上是考虑到了分区情况下的可用性,通过减少完成一次事务需要的参与者个数,来保障系统的可用性。
例如:N个服务节点,有(N/2)+1个节点达成共识,则认为系统达到了一致,并且按照Paxos原则,最终理论上也达到了一致,不会再改变,如此一来,只要保证有半数以上的服务存活,允许小部分服务挂掉,客户可以与大部分服务节点通信,那么就不会影响整体操作流程,也不需确保服务器全部处于工作状态,容错性非常好。操作影响的数据和结果随后会被异步的同步到其他节点上,从而保证最终一致性。
TCC分段事务提交
Try(预处理)-Confirm(确认)-Cancel(取消)模式的简称TCC。
Try阶段
业务检查(一致性)及资源预留(隔离),该阶段是一个初步操作,提交事务前的检查及预留业务资源完成;例如购票系统中的占位成功,需要在15分钟内支付;
Confirm阶段
确认执行业务操作,不在执行任何业务检查,基于Try阶段预留的业务资源,从理想状态下看只要Try成功,Confirm也会成功,因为资源的检查和锁定都已经成功;该阶段出现问题,需要重试机制或者手动处理;购票系统中的占位成功并且15分钟内支付完成,购票成功;
Cancel阶段
Cancel阶段是在业务执行错误需要回滚到状态下执行分支事务的取消,预留资源的释放;购票系统中的占位成功但是15分钟内没有支付,取消占位;
高级理论
RocketMQ事务消息
RocketMQ在4.3版中开始支持分布式事务消息,采用2PC的思想来实现了提交事务消息,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息。
上图说明了事务消息的大致方案,其中分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。
发送及提交
(1)发送消息(half消息,即发送但不被消费);
(2)服务端响应消息写入结果;
(3)根据发送结果执行本地事务,如果写入失败,此时half消息对业务不可见,本地逻辑不执行;
(4) 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)
补偿流程
(1)对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”;
(2)Producer收到回查消息,检查回查消息对应的本地事务的状态;
(3)根据本地事务状态,重新Commit或者Rollback;
其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况。
六、消息系统
涉及核心角色说明,从左向右依次:
- 生产客户端:需要请求服务端通信的节点,调用生产服务端封装的消息发送接口即可;
- 生产服务端:封装消息发送API,并维护路由管理,权限识别等,消息落地存储等;
- 消息存储层:主要基于消息中间件进行存储,数据库层面用来处理特定情况下的二次调度;
- 消费服务端:封装消息接收API,并根据路由标识,请求指定的消费端接口,完成通信;
- 消费客户端:响应消费服务端的请求,封装消费时具体的业务逻辑;
在整体的技术难度上没有太多差别,但是引入两个服务【生产和消费】,用来管理MQ通信流程,适配特定的业务逻辑,引入数据库做一次落地存储,在异常流程的处理上更加灵活,这样整个消息模块具有很强的可扩展性。
组件选型
消息中间件的选择是比较多的,但是鉴于业务线上开发人员的熟悉程度,以及参考多方提供的测试对比报告,最终确定选用RocketMQ组件,同时RocketMQ相关特点:高性能、高可靠性,以及对分布式事务的支持,也是核心的考虑因素。
微服务架构
基于当前微服务的架构模式,把MQ功能本身集成在两个核心服务中,进行统一管理和迭代,以及组件的版本控制,对于所有生产的消息,进行全局路由控制,以及特定情况下的,通过应用服务层面功能设计,实现消息延时消费,以及失败消息的二次调度执行,和部分单条消息的手动触发。
数据存储
对消息实体进行二次存储,主要还是适配部分特定的功能点,有些消息可以延时处理,例如当MQ队列出现堆积的时候,或者达到监控的预警线时,可以通过配置手段,干预一部分消息只存储入库,不推送MQ,等待服务相对空闲的时候再去发送。
消息中间件作为系统间解耦的稳定支撑,在服务层面管理时,需要具备清晰的设计路线,以及流程关键节点的监控和记录,确保整个链路的稳定和容错。
七、自动化部署
分布式系统架构下,服务发布是一件很麻烦的事情,特别是在构建自动发布流程和灰度测试的策略两个核心方面。通常情况下如果不涉及数据层面的灰度流程,服务可以灰度上线,或者滚动上线,这两种方式很常用;如果涉及到数据灰度,则可能需要中间服务做不同版本数据之间追平,或者停机维护一次性处理好数据和上线问题,不过后面这种方式风险较大。
蓝绿部署
滚动发布
滚动发布可以避免蓝绿部署的服务器资源占用问,首先发布一台新版本服务,然后停掉一台老版本服务,新版服务经过观察之后,再逐步替换掉所有老版本的服务,这样服务的环境变动比较频繁,相对不稳定。
Jenkins集成
Jenkins可以很方便的整合常用的代码仓库,例如:GitHub、SVN等,提供持续集成能力,可以把整个代码构建打包,部署做成自动管理流程,代码一经提交就会自动发布到指定环境下,极大减少非必要的工作量。
- 开发人员提交本地代码;
- 代码仓库通过Hook机制通知Jenkins;
- Jenkins获取最新代码编译打包;
- 生成Docker镜像文件上传到中心仓库;
- 最终触发滚动或者灰度等发布机制;
在整个代码发布过程如果出现问题,可以快速的回滚到上个版本,需要手动处理的流程极少,作为程序员这个职业,越是工作时间长,越要善用自动化的流程。系统架构越复杂,则服务部署、数据和环境隔离、容灾、灰度、动态扩容就更是需要自动管理,上述技术体系可以很轻松的解决这些问题。
Docker容器
Docker是作为开源的应用容器引擎,有三个核心概念,Image-镜像,Container-容器、Repository-仓库;开发人员可以通过打包应用和依赖包到一个可移植的容器中,容器是完全使用沙箱机制,相互之间不会有任何接口,然后发布到任何流行的服务器上,也可以实现虚拟化。上述微服务模块变多,需要持续集成工具管理;同理当Docker容器变多和复杂,管理和调度也是一个问题。
K8S容器管理
Kubernetes简称K8S,用做灵活和便捷管理和调度Docker容器,提供应用部署、规划、更新、维护的一种机制,让部署容器化的应用简单并且高效,支持自动化部署、大规模可伸缩、应用容器化管理。
在上面的部署环境架构下,Docker可以理解为Kubernetes上的一个组件,通过K8S去统一管理。
总结
分布式系统重点考虑的问题除了以上七种,还有很多问题需要处理,任重而道远,这是最基础的七个方向,在一线互联网公司的面试名单上,上面每一个都很重要,作为一个架构师,上面就是架构的基础,后面想ES,大数据Hadoop,微服务组件,各个都是重点。
更多推荐
所有评论(0)