首页 文章 精选 留言 我的

精选列表

搜索[赛博朋克],共10000篇文章
优秀的个人博客,低调大师

每日一博 | 品味 Spring Cache 设计之美

最近负责教育类产品的架构工作,两位研发同学建议:“团队封装的Redis客户端可否适配Spring Cache,这样加缓存就会方便多了” 。 于是边查阅文档边实战,收获颇丰,写这篇文章,想和大家分享笔者学习的过程,一起品味Spring Cache设计之美。 1 硬编码 在学习Spring Cache之前,笔者经常会硬编码的方式使用缓存。 举个例子,为了提升用户信息的查询效率,我们对用户信息使用了缓存,示例代码如下: @Autowire privateUserMapperuserMapper; @Autowire privateStringCommandstringCommand; //查询用户 publicUsergetUserById(LonguserId){ StringcacheKey="userId_"+userId; Useruser=stringCommand.get(cacheKey); if(user!=null){ returnuser; } user=userMapper.getUserById(userId); if(user!=null){ stringCommand.set(cacheKey,user); returnuser; } //修改用户 publicvoidupdateUser(Useruser){ userMapper.updateUser(user); StringcacheKey="userId_"+userId.getId(); stringCommand.set(cacheKey,user); } //删除用户 publicvoiddeleteUserById(LonguserId){ userMapper.deleteUserById(userId); StringcacheKey="userId_"+userId.getId(); stringCommand.del(cacheKey); } } 相信很多同学都写过类似风格的代码,这种风格符合面向过程的编程思维,非常容易理解。但它也有一些缺点: 代码不够优雅。业务逻辑有四个典型动作:存储,读取,修改,删除。每次操作都需要定义缓存Key ,调用缓存命令的API,产生较多的重复代码; 缓存操作和业务逻辑之间的代码耦合度高,对业务逻辑有较强的侵入性。 侵入性主要体现如下两点: 开发联调阶段,需要去掉缓存,只能注释或者临时删除缓存操作代码,也容易出错; 某些场景下,需要更换缓存组件,每个缓存组件有自己的API,更换成本颇高。 2 缓存抽象 首先需要明确一点:Spring Cache不是一个具体的缓存实现方案,而是一个对缓存使用的抽象(Cache Abstraction)。 2.1 Spring AOP Spring AOP是基于代理模式(proxy-based)。 通常情况下,定义一个对象,调用它的方法的时候,方法是直接被调用的。 Pojopojo=newSimplePojo(); pojo.foo(); 将代码做一些调整,pojo对象的引用修改成代理类。 ProxyFactoryfactory=newProxyFactory(newSimplePojo()); factory.addInterface(Pojo.class); factory.addAdvice(newRetryAdvice()); Pojopojo=(Pojo)factory.getProxy(); //thisisamethodcallontheproxy! pojo.foo(); 调用pojo的foo方法的时候,实际上是动态生成的代理类调用foo方法。 代理类在方法调用前可以获取方法的参数,当调用方法结束后,可以获取调用该方法的返回值,通过这种方式就可以实现缓存的逻辑。 2.2 缓存声明 缓存声明,也就是标识需要缓存的方法以及缓存策略。 Spring Cache 提供了五个注解。 @Cacheable:根据方法的请求参数对其结果进行缓存,下次同样的参数来执行该方法时可以直接从缓存中获取结果,而不需要再次执行该方法; @CachePut:根据方法的请求参数对其结果进行缓存,它每次都会触发真实方法的调用; @CacheEvict:根据一定的条件删除缓存; @Caching:组合多个缓存注解; @CacheConfig:类级别共享缓存相关的公共配置。 我们重点讲解:@Cacheable,@CachePut,@CacheEvict三个核心注解。 2.2.1 @Cacheable注解 @Cacheble注解表示这个方法有了缓存的功能。 @Cacheable(value="user_cache",key="#userId",unless="#result==null") publicUsergetUserById(LonguserId){ Useruser=userMapper.getUserById(userId); returnuser; } 上面的代码片段里,getUserById方法和缓存user_cache 关联起来,若方法返回的User对象不为空,则缓存起来。第二次相同参数userId调用该方法的时候,直接从缓存中获取数据,并返回。 ▍ 缓存key的生成 我们都知道,缓存的本质是key-value存储模式,每一次方法的调用都需要生成相应的Key, 才能操作缓存。 通常情况下,@Cacheable有一个属性key可以直接定义缓存key,开发者可以使用SpEL语言定义key值。 若没有指定属性key,缓存抽象提供了 KeyGenerator来生成key ,默认的生成器代码见下图: 它的算法也很容易理解: 如果没有参数,则直接返回SimpleKey.EMPTY; 如果只有一个参数,则直接返回该参数; 若有多个参数,则返回包含多个参数的SimpleKey对象。 当然Spring Cache也考虑到需要自定义Key生成方式,需要我们实现org.springframework.cache.interceptor.KeyGenerator 接口。 Objectgenerate(Objecttarget,Methodmethod,Object...params); 然后指定@Cacheable的keyGenerator属性。 @Cacheable(value="user_cache",keyGenerator="myKeyGenerator",unless="#result==null") publicUsergetUserById(LonguserId) ▍ 缓存条件 有的时候,方法执行的结果是否需要缓存,依赖于方法的参数或者方法执行后的返回值。 注解里可以通过condition属性,通过Spel表达式返回的结果是true 还是false 判断是否需要缓存。 @Cacheable(cacheNames="book",condition="#name.length()<32") publicBookfindBook(Stringname) 上面的代码片段里,当参数的长度小于32,方法执行的结果才会缓存。 除了condition,unless属性也可以决定结果是否缓存,不过是在执行方法后。 @Cacheable(value="user_cache",key="#userId",unless="#result==null") publicUsergetUserById(LonguserId){ 上面的代码片段里,当返回的结果为null则不缓存。 2.2.2 @CachePut注解 @CachePut注解作用于缓存需要被更新的场景,和 @Cacheable 非常相似,但被注解的方法每次都会被执行。 返回值是否会放入缓存,依赖于condition和unless,默认情况下结果会存储到缓存。 @CachePut(value="user_cache",key="#user.id",unless="#result!=null") publicUserupdateUser(Useruser){ userMapper.updateUser(user); returnuser; } 当调用updateUser方法时,每次方法都会被执行,但是因为unless属性每次都是true,所以并没有将结果缓存。当去掉unless属性,则结果会被缓存。 2.2.3 @CacheEvict注解 @CacheEvict 注解的方法在调用时会从缓存中移除已存储的数据。 @CacheEvict(value="user_cache",key="#id") publicvoiddeleteUserById(Longid){ userMapper.deleteUserById(id); } 当调用deleteUserById方法完成后,缓存key等于参数id的缓存会被删除,而且方法的返回的类型是Void ,这和@Cacheable明显不同。 2.3 缓存配置 Spring Cache是一个对缓存使用的抽象,它提供了多种存储集成。 要使用它们,需要简单地声明一个适当的CacheManager - 一个控制和管理Cache的实体。 我们以Spring Cache默认的缓存实现Simple例子,简单探索下CacheManager的机制。 CacheManager非常简单: publicinterfaceCacheManager{ @Nullable CachegetCache(Stringname); Collection<String>getCacheNames(); } 在CacheConfigurations配置类中,可以看到不同集成类型有不同的缓存配置类。 通过SpringBoot的自动装配机制,创建CacheManager的实现类ConcurrentMapCacheManager。 而ConcurrentMapCacheManager的getCache方法,会创建ConcurrentCacheMap。 ConcurrentCacheMap实现了org.springframework.cache.Cache接口。 从Spring Cache的Simple的实现,缓存配置需要实现两个接口: org.springframework.cache.CacheManager org.springframework.cache.Cache 3 入门例子 首先我们先创建一个工程spring-cache-demo。 caffeine和Redisson分别是本地内存和分布式缓存Redis框架中的佼佼者,我们分别演示如何集成它们。 3.1 集成caffeine 3.1.1 maven依赖 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>2.7.0</version> </dependency> 3.1.2 Caffeine缓存配置 我们先创建一个缓存配置类MyCacheConfig。 @Configuration @EnableCaching publicclassMyCacheConfig{ @Bean publicCaffeinecaffeineConfig(){ return Caffeine.newBuilder() .maximumSize(10000). expireAfterWrite(60,TimeUnit.MINUTES); } @Bean publicCacheManagercacheManager(Caffeinecaffeine){ CaffeineCacheManagercaffeineCacheManager=newCaffeineCacheManager(); caffeineCacheManager.setCaffeine(caffeine); returncaffeineCacheManager; } } 首先创建了一个Caffeine对象,该对象标识本地缓存的最大数量是10000条,每个缓存数据在写入60分钟后失效。 另外,MyCacheConfig类上我们添加了注解:@EnableCaching。 3.1.3 业务代码 根据缓存声明这一节,我们很容易写出如下代码。 @Cacheable(value="user_cache",unless="#result==null") publicUsergetUserById(Longid){ returnuserMapper.getUserById(id); } @CachePut(value="user_cache",key="#user.id",unless="#result==null") publicUserupdateUser(Useruser){ userMapper.updateUser(user); returnuser; } @CacheEvict(value="user_cache",key="#id") publicvoiddeleteUserById(Longid){ userMapper.deleteUserById(id); } 这段代码与硬编码里的代码片段明显精简很多。 当我们在Controller层调用 getUserById方法时,调试的时候,配置mybatis日志级别为DEBUG,方便监控方法是否会缓存。 第一次调用会查询数据库,打印相关日志: Preparing:select*FROMusertwheret.id=? Parameters:1(Long) Total:1 第二次调用查询方法的时候,数据库SQL日志就没有出现了, 也就说明缓存生效了。 3.2 集成Redisson 3.2.1 maven依赖 <dependency> <groupId>org.Redisson</groupId> <artifactId>Redisson</artifactId> <version>3.12.0</version> </dependency> 3.2.2 Redisson缓存配置 @Bean(destroyMethod="shutdown") publicRedissonClientRedisson(){ Configconfig=newConfig(); config.useSingleServer() .setAddress("redis://127.0.0.1:6201").setPassword("ts112GpO_ay"); returnRedisson.create(config); } @Bean CacheManagercacheManager(RedissonClientRedissonClient){ Map<String,CacheConfig>config=newHashMap<String,CacheConfig>(); //create"user_cache"springcachewithttl=24minutesandmaxIdleTime=12minutes config.put("user_cache", newCacheConfig( 24*60*1000, 12*60*1000)); returnnewRedissonSpringCacheManager(RedissonClient,config); } 可以看到,从Caffeine切换到Redisson,只需要修改缓存配置类,定义CacheManager 对象即可。而业务代码并不需要改动。 Controller层调用 getUserById方法,用户ID为1的时候,可以从Redis Desktop Manager里看到: 用户信息已被缓存,user_cache缓存存储是Hash数据结构。 因为Redisson默认的编解码是FstCodec, 可以看到key的名称是: \xF6\x01。 在缓存配置代码里,可以修改编解码器。 publicRedissonClientRedisson(){ Configconfig=newConfig(); config.useSingleServer() .setAddress("redis://127.0.0.1:6201").setPassword("ts112GpO_ay"); config.setCodec(newJsonJacksonCodec()); returnRedisson.create(config); } 再次调用 getUserById方法 ,控制台就变成: 可以观察到:缓存key已经变成了:["java.lang.Long",1],改变序列化后key和value已发生了变化。 3.3 从列表缓存再次理解缓存抽象 列表缓存在业务中经常会遇到。通常有两种实现形式: 整体列表缓存; 按照每个条目缓存,通过redis,memcached的聚合查询方法批量获取列表,若缓存没有命中,则从数据库重新加载,并放入缓存里。 那么Spring cache整合Redisson如何缓存列表数据呢? @Cacheable(value="user_cache") publicList<User>getUserList(List<Long>idList){ returnuserMapper.getUserByIds(idList); } 执行getUserList方法,参数id列表为:[1,3] 。 执行完成之后,控制台里可以看到:列表整体直接被缓存起来,用户列表缓存和用户条目缓存并没有共享,他们是平行的关系。 这种情况下,缓存的颗粒度控制也没有那么细致。 类似这样的思考,很多开发者也向Spring Framework研发团队提过。 官方的回答也很明确:对于缓存抽象来讲,它并不关心方法返回的数据类型,假如是集合,那么也就意味着需要把集合数据在缓存中保存起来。 还有一位开发者,定义了一个@CollectionCacheable注解,并做出了原型,扩展了Spring Cache的列表缓存功能。 @Cacheable("myCache") publicStringfindById(Stringid){ //accessDBbackendreturnitem } @CollectionCacheable("myCache") publicMap<String,String>findByIds(Collection<String>ids){ //accessDBbackend,returnmapofidtoitem } 官方也未采纳,因为缓存抽象并不想引入太多的复杂性。 写到这里,相信大家对缓存抽象有了更进一步的理解。当我们想实现更复杂的缓存功能时,需要对Spring Cache做一定程度的扩展。 4 自定义二级缓存 4.1 应用场景 笔者曾经在原来的项目,高并发场景下多次使用多级缓存。多级缓存是一个非常有趣的功能点,值得我们去扩展。 多级缓存有如下优势: 离用户越近,速度越快; 减少分布式缓存查询频率,降低序列化和反序列化的CPU消耗; 大幅度减少网络IO以及带宽消耗。 进程内缓存做为一级缓存,分布式缓存做为二级缓存,首先从一级缓存中查询,若能查询到数据则直接返回,否则从二级缓存中查询,若二级缓存中可以查询到数据,则回填到一级缓存中,并返回数据。若二级缓存也查询不到,则从数据源中查询,将结果分别回填到一级缓存,二级缓存中。 来自《凤凰架构》缓存篇 Spring Cache并没有二级缓存的实现,我们可以实现一个简易的二级缓存DEMO,加深对技术的理解。 4.2 设计思路 MultiLevelCacheManager:多级缓存管理器; MultiLevelChannel:封装Caffeine和RedissonClient; MultiLevelCache:实现org.springframework.cache.Cache接口; MultiLevelCacheConfig:配置缓存过期时间等; MultiLevelCacheManager是最核心的类,需要实现getCache和getCacheNames两个接口。 创建多级缓存,第一级缓存是:Caffeine , 第二级缓存是:Redisson。 二级缓存,为了快速完成DEMO,我们使用Redisson对Spring Cache的扩展类RedissonCache 。它的底层是RMap,底层存储是Hash。 我们重点看下缓存的「查询」和「存储」的方法: @Override publicValueWrapperget(Objectkey){ Objectresult=getRawResult(key); returntoValueWrapper(result); } publicObjectgetRawResult(Objectkey){ logger.info("从一级缓存查询key:"+key); Objectresult=localCache.getIfPresent(key); if(result!=null){ returnresult; } logger.info("从二级缓存查询key:"+key); result=RedissonCache.getNativeCache().get(key); if(result!=null){ localCache.put(key,result); } returnresult; } 「查询」数据的流程: 先从本地缓存中查询数据,若能查询到,直接返回; 本地缓存查询不到数据,查询分布式缓存,若可以查询出来,回填到本地缓存,并返回; 若分布式缓存查询不到数据,则默认会执行被注解的方法。 下面来看下「存储」的代码: publicvoidput(Objectkey,Objectvalue){ logger.info("写入一级缓存key:"+key); localCache.put(key,value); logger.info("写入二级缓存key:"+key); RedissonCache.put(key,value); } 最后配置缓存管理器,原有的业务代码不变。 执行下getUserById方法,查询用户编号为1的用户信息。 -从一级缓存查询key:1 -从二级缓存查询key:1 -==>Preparing:select*FROMusertwheret.id=? -==>Parameters:1(Long) -<==Total:1 -写入一级缓存key:1 -写入二级缓存key:1 第二次执行相同的动作,从日志可用看到从优先会从本地内存中查询出结果。 -从一级缓存查询key:1 等待30s , 再执行一次,因为本地缓存会失效,所以执行的时候会查询二级缓存 -从一级缓存查询key:1 -从二级缓存查询key:1 一个简易的二级缓存就组装完了。 5 什么场景选择Spring Cache 在做技术选型的时候,需要针对场景选择不同的技术。 笔者认为Spring Cache的功能很强大,设计也非常优雅。特别适合缓存控制没有那么细致的场景。比如门户首页,偏静态展示页面,榜单等等。这些场景的特点是对数据实时性没有那么严格的要求,只需要将数据源缓存下来,过期之后自动刷新即可。 这些场景下,Spring Cache就是神器,能大幅度提升研发效率。 但在高并发大数据量的场景下,精细的缓存颗粒度的控制上,还是需要做功能扩展。 多级缓存; 列表缓存; 缓存变更监听器; 笔者也在思考这几点的过程,研读了 j2cache , jetcache相关源码,受益匪浅。后续的文章会重点分享下笔者的心得。 如果我的文章对你有所帮助,还请帮忙点赞、在看、转发一下,你的支持会激励我输出更高质量的文章,非常感谢!

优秀的个人博客,低调大师

每日一博 | 超基础的机器学习入门

前言— 随着前端智能化的火热,AI机器学习进入前端开发者们的视野。AI能够解决编程领域不能直接通过规则和运算解决的问题,通过自动推理产出最佳策略,成为了前端工程师们解决问题的又一大利器。 可能很多同学都跃跃欲试过,打开 TensorFlow 或者 Pytorch 官网,然后按照文档想要写一个机器学习的 Hello World ,然后就会遇到一些不知道是什么的函数,跑完例子却一头雾水,这是因为 TensorFlow 和 Pytorch 是使用机器学习的工具,而没有说明什么是机器学习。所以这篇文章以实践为最终目的出发,介绍一些机器学习入门的基本原理,加上一丢丢图像处理的卷积,希望可以帮助你理解。 基础概念— 首先,什么是机器学习?机器学习约等于找这样一个函数,比如在语音识别中,输入一段语音,输出文字内容 在图像识别中,输入一张图像,输出图中的对象, 在围棋中,输入棋盘数据,输出下一步怎么走, 在对话系统中,输入一句 hi ,输出一句回应, 而这个函数,是由你写的程序加上大量的数据,然后由机器自己学习到的。 怎么找这样一个函数呢,让我们从线性模型入手。线性模型形式简单,易于建模,但是蕴含着机器学习中一些重要的基本思想,许多功能更为强大的非线性模型都可在线性模型的基础上通过引入层级结构或高维映射而得到。 线性模型— 我们以一个猫和狗的分类来看,我们在教一个小朋友区分猫和狗的时候,并不会给到一个维奇百科的定义,而是不断的让小朋友看到猫和狗,让他判断,然后告诉他正确答案,纠结错误认知。机器学习也是同理,不断告知计算机怎样是正确的,纠正计算机的认知,不同的是,小朋友的认知是人脑自动处理完成的,而计算机并不能自动的构建猫和狗的记忆,计算机只认识数字。 所以我们需要提取出代表猫和狗的特征,然后用数字来表示。为了简化例子,我们这里只用到两个特征,鼻子的大小以及耳朵的形状,一般来说猫猫的鼻子更小,耳朵更尖,而狗狗鼻子比较大,耳朵比较圆。 我们对多张图片,统计图片中耳朵以及鼻子特征,在一个二维坐标中表现出来,可以看到猫猫和狗狗会分布在坐标系的不同区域。 肉眼可见,我们可以用一条直线来区分,但是,计算机并看不到哪里可以画条线。如何将信息传递给计算机呢,让我们定义两个变量,x1 表示鼻子大小,x2 表示耳朵形状,再定义这样一个直线方程 W1 · X1 + W2 · X2 - b = 0,也就相当于,令y=W1 · X1 + W2 · X2 - b ,当 y 大于0,判断是猫,当 y 小于 0 ,判断是狗。 现在,从计算机的角度来看,它拥有了一堆数据, 以及一个线性模型, 还差一个目标/任务,我们的期望是,当给一个没有见过的 x ,通过 f(x) ,可以得到一个预测值 y ,这个 y 要能够尽可能的贴近真实的值,这样,就有了一台有用的萌宠分类机了!这样的目标如何用数字来表示呢,这就要引入一个概念损失函数(Loss function)了,损失函数计算的是预测值与真实值之间的差距。 常用的损失函数有绝对值损失函数(Absolute value loss),也就是两个数值差的绝对值,就很直观,距离目标差多少,加起来,就酱 还有平方损失函数(最小二乘法, Least squares loss) 平方损失函数的目标是让每个点到回归直线的距离最小,这个距离算的是欧几里得距离。现在,我们给计算机的目标就变成了求一个最小值, 为了求这个值,让我们回忆一下久违的微积分,(同样,为了简化到二维坐标系,假设只有一个需要求的 w ),导数为 0 的地方即是函数的极大值或者极小值。 对于图中这样一个简单的一元二次方程,我们可以直接对参数 w 求导,求得极小值。但是,如果是下图中这样一个函数呢,就..不好求了,而且对于不同的函数求导有不同的公式,那就..比较麻烦了,毕竟我们的目标是让机器自己学习,是吧。 所以,我们需要一个更通用的计算方法,那就是梯度下降(Gradient descent, 梯度下降的基本流程如下,首先,我们随机取一个点作为初始值,计算这个点的斜率,也就是导数。 当斜率为负的时候,往右走一小步, 当斜率为正的时候,往左走一小步, 在每个点上重复,计算新的斜率,再适当的走一小步,就会逼近函数的某个局部最小值,就像一个小球从山上滚下来,不过初始位置不同,会到达不同的局部最小值,无法保证是全局最小,但是,其实,大部分情况我们根据问题抽象的函数基本都是凸函数,能够得到一个极小值,在极小值不唯一的情况下,也可以加入随机数,来给到一个跳出当前极小值区域的机会。我们需要明确的是,机器学习的理论支撑是概率论与统计学,我们通过机器学习寻找的问题答案,往往不是最优解,而是一个极优解。 想象一个更复杂的有两个输入一个输出的二元函数,我们的 loss function 可以呈现为三维空间中的一个曲面,问题就变成了,曲面上某个点要往空间中哪个方向走,才能让结果下降得最快。 步骤依旧是,计算梯度,更新,计算,更新....用公示来表示就是如下, 这时候,我们就遇到了第一个超参数 η ,即学习率(Learning rate),机器学习中的参数分为两类,模型参数与超参数,模型参数是 w 这种,让机器自己去学习的,超参数则是在模型训练之前由开发人员指定的。 通过上面的公式,可以看到 是 Loss function 函数对于参数 w 的导数,决定了我们走的方向,那么学习率则决定了在这个方向每一小部走的距离。 当 η 太小,到达极小值的过程会非常的缓慢,而如果 η 太大,则会因为步伐太大,直接越过最低点。那么,η 的值要怎么取呢, 比较常规的做法是,以从 0.1 这样的值开始,然后再指数下降,取0.01,0.001,当我们用一个很大的学习率,会发现损失函数的值几乎没有下降,那可能就是在摇摆,当我们取到一个较小的值,能够让损失函数下降,那么继续往下取,不断缩小范围,这个过程也可以通过计算机自动来做,如果有计算资源的话。 了解了梯度下降、学习率后,我们已经可以使用线性模型解决比较简单的问题了, 基本步骤: 提取特征 设定模型 计算梯度,更新 是不是想试一下了! 这里有一个简单的房价预测的栗子,可以本地跑跑看,试试调整不同的学习率,看 loss function 的变化。https://github.com/xs7/MachineLearning-demo/blob/master/RegressionExperiment.ipynb 其中关键代码如下: #损失函数deflossFunction(x,y,w,b):cost=np.sum(np.square(x*w+b-y))/(2*x.shape[0])returncost#求导defderivation(x,y,w,b):#wd=((x*w+b-y)*x)/x.shape[0]wd=x.T.dot(x.dot(w)+b-y)/x.shape[0]bd=np.sum(x*w+b-y)/x.shape[0]returnwd,bd#线性回归模型deflinearRegression(x_train,x_test,y_train,y_test,delta,num_iters):w=np.zeros(x.shape[1])#初始化w参数b=0#初始化b参数trainCost=np.zeros(num_iters)#初始化训练集上的lossvalidateCost=np.zeros(num_iters)#初始化验证集上的lossforiinrange(num_iters):#开始迭代啦trainCost[i]=lossFunction(x_train,y_train,w,b)#计算训练集上lossvalidateCost[i]=lossFunction(x_test,y_test,w,b)#计算测试集上lossGw,Gb=derivation(x_train,y_train,w,b);#计算训练集上导数Dw=-Gw#斜率>0往负方向走,所以需要加负号Db=-Gb#同上w=w+delta*Dw#更新参数wb=b+delta*Db#更新参数breturntrainCost,validateCost,w,b 多层感知机— 我们刚刚说到的线性模型,实际上是一个单层的网络,它包括了机器学习的基本要素,模型、训练数据、损失函数和优化算法。但是受限于线性运算,并不能解决更加复杂的问题。 我们需要更为通用的模型来适应不同的数据。比如多加一层?加一层的效果约等于对坐标轴进行变换,可以做更复杂一丢丢的问题了。 但是依旧是线性模型,没有办法解决非线性问题,比如下图中,没有办法用一条直线分开,但是用 y= x2 这样一个二元一次方程就可以轻轻松松,这就是非线性的好处了。 加一个非线性的结构,也就引入了神经网络中另一个基本概念,激活函数(Activation Function),常见的激活函数如下 Relu 函数只保留正数元素,清零负数元素,sigmoid 函数可以把元素的值变换到 0~1 之间,tanh 函数可以把元素的值变换到 -1~1 之间。其中用到最广泛的是看上去最简单的 Relu ,Relu函数就好比人脑神经元,达到神经元的刺激阈值就输出,达不到阈值就置零。 激活函数的选择要考虑到输入输出以及数据的变化,比如通常会用 sigmoid 作为输出层的激活函数,比如做分类任务的时候,将结果映射到 0~1 ,对于每个预设的类别给到一个 0~1 的预测概率值。 可以理解为,我们提供了非线性的函数,然后神经网络通过自己学习,使用我们提供的非线性元素,可以逼近任意一个非线性函数,于是可以应用到众多的非线性模型中。 加入激活函数后,我们就拥有了多层感知机(multi layer perceptron),多层感知机就是含有至少一个隐藏层的由全连接层组成的神经网络,且每个隐藏层的输出通过激活函数进行变换。 类似上图这样,就构成了一个简单的多层感知网络,即深度神经网络。网络层级变复杂之后,依旧是使用梯度下降来进行迭代优化,但是梯度的计算却变复杂了,网络中的每条线上都有一个 w 权重参数,需要用 loss function 对每个 w 求梯度,大概估一下,假设输入层有10个节点,有两个隐藏层,每个隐藏层隐藏层那从输入层到隐藏层 1 再到隐藏层 2 就有 30000*3 个参数,而且参数之间是存在函数关系的,最终输出的 loss 对第一层隐藏层的 w 求导需要逐层求过来,计算量++++++n, 直接求导是万万不可能的,所以我们需要反向传播算法(Backpropagation,bp算法)。 反向传播算法— 反向传播算法是用来在多层网络中快捷的计算梯度的,其推导相对而言要复杂一些,使用框架的时候..直接调用api即可,也没有什么开发者能调整的地方,大家应该..不想写代码计算偏导数吧..那就作为进阶内容,先挖个坑..下次来填.. 中途小结— 到现在我们应该对神经网络的计算已经有了一个基本的印象,回顾一下, 就是给到一个多层网络结构模型,然后输入数据,不断求梯度来更新模型的参数,不断减少模型预测的误差。其中使用梯度来更新参数的步伐由超参数学习率决定,用伪代码表示就是: foriin迭代次数:loss=预测值和真实值的差距 d = loss 对 w求导w=w-d*学习率 到这里,我们已经知道了一个深度神经网络基本结构以及计算流程,可以看懂一些简单的使用神经网络的代码了,继续回去看 Pytorch 官方教程,结果 demo 里面都是图像的栗子,所以..那就再看看什么是卷积神经网络叭。 卷积神经网络— 我们前面提到的网络模型中,相邻两层之间任意两个节点之间都有连接,称之为全连接网络(Fully Connected Layer)。当我们用一个深度网络模型处理图片,可以把图片中每个像素的 rgb 值均作为输入,一张 100*100 的图片,网络的输入层就有 100*100*3 个节点,哪怕只给一个隐藏层,输入层到隐藏层就已经有 30000*100 个参数了,再添加几层或者换稍微大一点的图片,参数数量就更爆炸了。图像需要处理的数据量太大,使用全连接网络计算的成本太高,而且效率很低。直到卷积神经网络(Convolutional Neural Network, CNN)出现,才解决了图像处理的难题。 直接来看卷积神经网络是什么样子的吧,如下 一个典型的卷积神经网络包括了三部分 卷积层 池化层 全连接层 其中,卷积层用来提取图片的特征,池化层用来减少参数,全连接层用来输出我们想要的结果。 先来看卷积。 输入一张图片,再给到一个卷积核(kernel,又称为filter滤波器) 将滤波器在图像上滑动,对应位置相乘求和, 滑完可以得到一个新的二维数组,这就是卷积运算了,是的..就是这样简单的加法。 如果再加一个卷积核,运算完毕就得到了两个通道的数组。 二维卷积层输出的二维数组可以看作是输入在空间维度(宽和高)上某一级的表征,也叫特征图(feature map)。 一个节点的输入来源区域称为其感受野(receptive field),比如特征图中第一个节点 3 的输入野就是输入图片左上角 3*3 的区域。 如果我们对结果再来一次卷积,最后得到的特征图中的第一个节点 17 ,其感受野就变成了其输入节点的感受野的并集,即图片左上角 4*4 的区域。我们可以通过更深的卷积神经网络使特征图中单个元素的感受野变得更加广阔,从而捕捉输入上更大尺寸的特征。 这其实是模拟人类视觉原理,当我们接收到视觉信号,大脑皮层的某些细胞会做初步处理,发现边缘以及方向,然后再进行抽象,判定眼前物体的形状是圆的还是方的,然后进一步抽象是什么物体。通过多层的神经网络,较低层的神经元识别初级的图像特征,若干底层特征组成更上一层特征,最终得到最高抽象的特征来得到分类结果。 了解了多层卷积是从局部抽象到全局抽象这样一个识别过程后,再回过头来看一下卷积核本身。 从函数的角度来理解,卷积过程是在图像每个位置进行线性变换映射成新值的过程, 在进行逐层映射,整体构成一个复杂函数。从模版匹配的角度来说,卷积核定义了某种模式,卷积运算是在计算每个位置与该模式的相似程度,或者说每个位置具有该模式的分量有多少,当前位置与该模式越像,响应越强。 比如用边缘检测算子来做卷积,sobel 算子包含两组 3*3 的矩阵,分别为横向及纵向,将之与图像作平面卷积,如果以A代表原始图像,G(x) 及 G(y) 分别代表经横向及纵向边缘检测的图像: sobel 偏 x 方向的边缘检测计算结果如下所示: 再看一些直观表现不同卷积核算子效果的栗子, 是不是感觉卷积大法好。当然,我们可以直接找一些有趣的卷积核来用,比如用卷积来检测图像边缘,也可以通过数据来学习卷积核,让神经网络来学习到不同的算子。 刚刚在卷积计算的时候,每次滑动了一个小格,也就是 stride 步伐为 1,其实也可以把步伐加大,每次滑动 2 个小格,也可以跳着取值,来扩大感受野,也可以为了保持输出数组的长宽与输入一致,在原图边缘加一圈 padding ,作为最最基础的入门,这里就不展开了。 回到我们的网络结构,可以看到两层神经元间只有部分连接了,更少的连接,代表更少的参数。 但是这样还不够,图片像素太多,哪怕我们只对局部取特征,依旧需要许多许多的参数,所以还需要池化(pooling)。 池化层的作用其实就是下采样,缩小图片,池化的计算也非常的简单,对输出数据的一个固定大小窗口的元素进行计算,然后输出,最大池化(Max Pooling) 就是取池化窗口内元素的最大值,平均池化则是取输入窗口的元素的平均值。 除了下采样,减小图片大小,池化还可以缓解卷积层对位置的过度敏感性,避免模型过拟合,举一个极端的例子,一张图片只有四个像素,如果某个位置像素为 255 ,我们就判定是某个类型的物品,如果我们输入的用来学习的训练集图片中,每张图片都是左上角第一个像素为 255 ,如果没有池化,模型训练的结果就是,当左上角第一个像素为 255 ,那么输出判断为该物品,当我们用这个模型去预测一张右上角像素为255的图片,模型会认为不是该物体,判断错误。而如果有池化,不管 255 出现在哪一个位置,池化后都会取到 255,判断为是该物品。 经过多个卷积层和池化层降维,数据就来到了全连接层,进行高层级抽象特征的分类啦。 最后— 到这里,应该已经介绍完看懂 Pytorch / Tensorflow 官网入门教程所需要的绝大部分原理知识了,可以愉快的跑官网的图片分类示例然后写自己的网络了。具体框架使用那就下篇《超基础的机器学习入门-实践篇》见。 最后的最后— Deco 智能代码项目是凹凸实验室在「前端智能化」方向上的探索,我们尝试从设计稿生成代码(DesignToCode)这个切入点入手,对现有的设计到研发这一环节进行能力补全,进而提升产研效率。其中使用到不少AI能力来实现设计稿的解析与识别,感兴趣的童鞋欢迎关注我们的账号「凹凸实验室」(知乎[1]、掘金[2])。 参考资料— [1]知乎: https://www.zhihu.com/people/o2team [2]掘金: https://juejin.cn/user/1134351699149854 [3]http://speech.ee.ntu.edu.tw/~tlkagk/courses_ML19.html [4]https://www.jiqizhixin.com/articles/2017-11-17-2 [5]https://www.cnblogs.com/shine-lee/p/9932226.html [6]https://www.jianshu.com/p/2334bee37de5 [7]https://zh.wikipedia.org/wiki/%E7%B4%A2%E8%B2%9D%E7%88%BE%E7%AE%97%E5%AD%90 [8]https://medium.com/@pkqiang49/%E4%B8%80%E6%96%87%E7%9C%8B%E6%87%82%E5%8D%B7%E7%A7%AF%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9C-cnn-%E5%9F%BA%E6%9C%AC%E5%8E%9F%E7%90%86-%E7%8B%AC%E7%89%B9%E4%BB%B7%E5%80%BC-%E5%AE%9E%E9%99%85%E5%BA%94%E7%94%A8-6047fb2add35 [9]http://cs231n.stanford.edu/ 本文分享自微信公众号 - 凹凸实验室(AOTULabs)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | Redis 线程模型的前世今生

一、概述 众所周知,Redis是一个高性能的数据存储框架,在高并发的系统设计中,Redis也是一个比较关键的组件,是我们提升系统性能的一大利器。深入去理解Redis高性能的原理显得越发重要,当然Redis的高性能设计是一个系统性的工程,涉及到很多内容,本文重点关注Redis的IO模型,以及基于IO模型的线程模型。 我们从IO的起源开始,讲述了阻塞IO、非阻塞IO、多路复用IO。基于多路复用IO,我们也梳理了几种不同的Reactor模型,并分析了几种Reactor模型的优缺点。基于Reactor模型我们开始了Redis的IO模型和线程模型的分析,并总结出Redis线程模型的优点、缺点,以及后续的Redis多线程模型方案。本文的重点是对Redis线程模型设计思想的梳理,捋顺了设计思想,就是一通百通的事了。 注:本文的代码都是伪代码,主要是为了示意,不可用于生产环境。 二、网络IO模型发展史 我们常说的网络IO模型,主要包含阻塞IO、非阻塞IO、多路复用IO、信号驱动IO、异步IO,本文重点关注跟Redis相关的内容,所以我们重点分析阻塞IO、非阻塞IO、多路复用IO,帮助大家后续更好的理解Redis网络模型。 我们先看下面这张图; 2.1 阻塞IO 我们经常说的阻塞IO其实分为两种,一种是单线程阻塞,一种是多线程阻塞。这里面其实有两个概念,阻塞和线程。 阻塞:指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回; 线程:系统调用的线程个数。 像建立连接、读、写都涉及到系统调用,本身是一个阻塞的操作。 2.1.1 单线程阻塞 服务端单线程来处理,当客户端请求来临时,服务端用主线程来处理连接、读取、写入等操作。 以下用代码模拟了单线程的阻塞模式; import java.net.Socket; public class BioTest { public static void main(String[] args) throws IOException { ServerSocket server=new ServerSocket(8081); while(true) { Socket socket=server.accept(); System.out.println("accept port:"+socket.getPort()); BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream())); String inData=null; try { while ((inData = in.readLine()) != null) { System.out.println("client port:"+socket.getPort()); System.out.println("input data:"+inData); if("close".equals(inData)) { socket.close(); } } } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } } } 我们准备用两个客户端同时发起连接请求、来模拟单线程阻塞模式的现象。同时发起连接,通过服务端日志,我们发现此时服务端只接受了其中一个连接,主线程被阻塞在上一个连接的read方法上。 我们尝试关闭第一个连接,看第二个连接的情况,我们希望看到的现象是,主线程返回,新的客户端连接被接受。 从日志中发现,在第一个连接被关闭后,第二个连接的请求被处理了,也就是说第二个连接请求在排队,直到主线程被唤醒,才能接收下一个请求,符合我们的预期。 此时不仅要问,为什么呢? 主要原因在于accept、read、write三个函数都是阻塞的,主线程在系统调用的时候,线程是被阻塞的,其他客户端的连接无法被响应。 通过以上流程,我们很容易发现这个过程的缺陷,服务器每次只能处理一个连接请求,CPU没有得到充分利用,性能比较低。如何充分利用CPU的多核特性呢?自然而然的想到了——多线程逻辑。 2.1.2 多线程阻塞 对工程师而言,代码解释一切,直接上代码。 BIO多线程 package net.io.bio; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; public class BioTest { public static void main(String[] args) throws IOException { final ServerSocket server=new ServerSocket(8081); while(true) { new Thread(new Runnable() { public void run() { Socket socket=null; try { socket = server.accept(); System.out.println("accept port:"+socket.getPort()); BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream())); String inData=null; while ((inData = in.readLine()) != null) { System.out.println("client port:"+socket.getPort()); System.out.println("input data:"+inData); if("close".equals(inData)) { socket.close(); } } } catch (IOException e) { e.printStackTrace(); } finally { } } }).start(); } } } 同样,我们并行发起两个请求; 两个请求,都被接受,服务端新增两个线程来处理客户端的连接和后续请求。 我们用多线程解决了,服务器同时只能处理一个请求的问题,但同时又带来了一个问题,如果客户端连接比较多时,服务端会创建大量的线程来处理请求,但线程本身是比较耗资源的,创建、上下文切换都比较耗资源,又如何去解决呢? 2.2 非阻塞 如果我们把所有的Socket(文件句柄,后续用Socket来代替fd的概念,尽量减少概念,减轻阅读负担)都放到队列里,只用一个线程来轮训所有的Socket的状态,如果准备好了就把它拿出来,是不是就减少了服务端的线程数呢? 一起看下代码,单纯非阻塞模式,我们基本上不用,为了演示逻辑,我们模拟了相关代码如下; package net.io.bio; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.List; import org.apache.commons.collections4.CollectionUtils; public class NioTest { public static void main(String[] args) throws IOException { final ServerSocket server=new ServerSocket(8082); server.setSoTimeout(1000); List<Socket> sockets=new ArrayList<Socket>(); while (true) { Socket socket = null; try { socket = server.accept(); socket.setSoTimeout(500); sockets.add(socket); System.out.println("accept client port:"+socket.getPort()); } catch (SocketTimeoutException e) { System.out.println("accept timeout"); } //模拟非阻塞:轮询已连接的socket,每个socket等待10MS,有数据就处理,无数据就返回,继续轮询 if(CollectionUtils.isNotEmpty(sockets)) { for(Socket socketTemp:sockets ) { try { BufferedReader in=new BufferedReader(new InputStreamReader(socketTemp.getInputStream())); String inData=null; while ((inData = in.readLine()) != null) { System.out.println("input data client port:"+socketTemp.getPort()); System.out.println("input data client port:"+socketTemp.getPort() +"data:"+inData); if("close".equals(inData)) { socketTemp.close(); } } } catch (SocketTimeoutException e) { System.out.println("input client loop"+socketTemp.getPort()); } } } } } } 系统初始化,等待连接; 发起两个客户端连接,线程开始轮询两个连接中是否有数据。 两个连接分别输入数据后,轮询线程发现有数据准备好了,开始相关的逻辑处理(单线程、多线程都可)。 再用一张流程图辅助解释下(系统实际采用文件句柄,此时用Socket来代替,方便大家理解)。 服务端专门有一个线程来负责轮询所有的Socket,来确认操作系统是否完成了相关事件,如果有则返回处理,如果无继续轮询,大家一起来思考下?此时又带来了什么问题呢。 CPU的空转、系统调用(每次轮询到涉及到一次系统调用,通过内核命令来确认数据是否准备好),造成资源的浪费,那有没有一种机制,来解决这个问题呢? 2.3 IO多路复用 server端有没专门的线程来做轮询操作(应用程序端非内核),而是由事件来触发,当有相关读、写、连接事件到来时,主动唤起服务端线程来进行相关逻辑处理。模拟了相关代码如下; IO多路复用 import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.nio.charset.Charset; import java.util.Iterator; import java.util.Set; public class NioServer { private static Charset charset = Charset.forName("UTF-8"); public static void main(String[] args) { try { Selector selector = Selector.open(); ServerSocketChannel chanel = ServerSocketChannel.open(); chanel.bind(new InetSocketAddress(8083)); chanel.configureBlocking(false); chanel.register(selector, SelectionKey.OP_ACCEPT); while (true){ int select = selector.select(); if(select == 0){ System.out.println("select loop"); continue; } System.out.println("os data ok"); Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()){ SelectionKey selectionKey = iterator.next(); if(selectionKey.isAcceptable()){ ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); //继续可以接收连接事件 selectionKey.interestOps(SelectionKey.OP_ACCEPT); }else if(selectionKey.isReadable()){ //得到SocketChannel SocketChannel client = (SocketChannel)selectionKey.channel(); //定义缓冲区 ByteBuffer buffer = ByteBuffer.allocate(1024); StringBuilder content = new StringBuilder(); while (client.read(buffer) > 0){ buffer.flip(); content.append(charset.decode(buffer)); } System.out.println("client port:"+client.getRemoteAddress().toString()+",input data: "+content.toString()); //清空缓冲区 buffer.clear(); } iterator.remove(); } } } catch (Exception e) { e.printStackTrace(); } } } 同时创建两个连接; 两个连接无阻塞的被创建; 无阻塞的接收读写; 再用一张流程图辅助解释下(系统实际采用文件句柄,此时用Socket来代替,方便大家理解)。 当然操作系统的多路复用有好几种实现方式,我们经常使用的select(),epoll模式这里不做过多的解释,有兴趣的可以查看相关文档,IO的发展后面还有异步、事件等模式,我们在这里不过多的赘述,我们更多的是为了解释Redis线程模式的发展。 三、NIO线程模型解释 我们一起来聊了阻塞、非阻塞、IO多路复用模式,那Redis采用的是哪种呢? Redis采用的是IO多路复用模式,所以我们重点来了解下多路复用这种模式,如何在更好的落地到我们系统中,不可避免的我们要聊下Reactor模式。 首先我们做下相关的名词解释; Reactor:类似NIO编程中的Selector,负责I/O事件的派发; Acceptor:NIO中接收到事件后,处理连接的那个分支逻辑; Handler:消息读写处理等操作类。 3.1 单Reactor单线程模型 处理流程 Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。 优点 模型比较简单,所有的处理过程都在一个连接里; 实现上比较容易,模块功能也比较解耦,Reactor负责多路复用和事件分发处理,Acceptor负责连接事件处理,Handler负责Scoket读写事件处理。 缺点 只有一个线程,连接处理和业务处理共用一个线程,无法充分利用CPU多核的优势。 在流量不是特别大、业务处理比较快的时候系统可以有很好的表现,当流量比较大、读写事件比较耗时情况下,容易导致系统出现性能瓶颈。 怎么去解决上述问题呢?既然业务处理逻辑可能会影响系统瓶颈,那我们是不是可以把业务处理逻辑单拎出来,交给线程池来处理,一方面减小对主线程的影响,另一方面利用CPU多核的优势。这一点希望大家要理解透彻,方便我们后续理解Redis由单线程模型到多线程模型的设计的思路。 3.2 单Reactor多线程模型 这种模型相对单Reactor单线程模型,只是将业务逻辑的处理逻辑交给了一个线程池来处理。 处理流程 Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。 Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。 优点 让主线程专注于通用事件的处理(连接、读、写),从设计上进一步解耦; 利用CPU多核的优势。 缺点 貌似这种模型已经很完美了,我们再思考下,如果客户端很多、流量特别大的时候,通用事件的处理(读、写)也可能会成为主线程的瓶颈,因为每次读、写操作都涉及系统调用。 有没有什么好的办法来解决上述问题呢?通过以上的分析,大家有没有发现一个现象,当某一个点成为系统瓶颈点时,想办法把他拿出来,交个其他线程来处理,那这种场景是否适用呢? 3.3 多Reactor多线程模型 这种模型相对单Reactor多线程模型,只是将Scoket的读写处理从mainReactor中拎出来,交给subReactor线程来处理。 处理流程 mainReactor主线程负责连接事件的监听和处理,当Acceptor处理完连接过程后,主线程将连接分配给subReactor; subReactor负责mainReactor分配过来的Socket的监听和处理,当有Socket事件过来时交个对应的Handler处理; Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。 优点 让主线程专注于连接事件的处理,子线程专注于读写事件吹,从设计上进一步解耦; 利用CPU多核的优势。 缺点 实现上会比较复杂,在极度追求单机性能的场景中可以考虑使用。 四、Redis的线程模型 4.1 概述 以上我们聊了,IO网路模型的发展历史,也聊了IO多路复用的reactor模式。那Redis采用的是哪种reactor模式呢?在回答这个问题前,我们先梳理几个概念性的问题。 Redis服务器中有两类事件,文件事件和时间事件。 文件事件:在这里可以把文件理解为Socket相关的事件,比如连接、读、写等; 时间时间:可以理解为定时任务事件,比如一些定期的RDB持久化操作。 本文重点聊下Socket相关的事件。 4.2 模型图 首先我们来看下Redis服务的线程模型图; IO多路复用负责各事件的监听(连接、读、写等),当有事件发生时,将对应事件放入队列中,由事件分发器根据事件类型来进行分发; 如果是连接事件,则分发至连接应答处理器;GET、SET等redis命令分发至命令请求处理器。 命令处理完后产生命令回复事件,再由事件队列,到事件分发器,到命令回复处理器,回复客户端响应。 4.3 一次客户端和服务端的交互流程 4.3.1 连接流程 连接过程 Redis服务端主线程监听固定端口,并将连接事件绑定连接应答处理器。 客户端发起连接后,连接事件被触发,IO多路复用程序将连接事件包装好后丢人事件队列,然后由事件分发处理器分发给连接应答处理器。 连接应答处理器创建client对象以及Socket对象,我们这里关注Socket对象,并产生ae_readable事件,和命令处理器关联,标识后续该Socket对可读事件感兴趣,也就是开始接收客户端的命令操作。 当前过程都是由一个主线程负责处理。 4.3.2 命令执行流程 SET命令执行过程 客户端发起SET命令,IO多路复用程序监听到该事件后(读事件),将数据包装成事件丢到事件队列中(事件在上个流程中绑定了命令请求处理器); 事件分发处理器根据事件类型,将事件分发给对应的命令请求处理器; 命令请求处理器,读取Socket中的数据,执行命令,然后产生ae_writable事件,并绑定命令回复处理器; IO多路复用程序监听到写事件后,将数据包装成事件丢到事件队列中,事件分发处理器根据事件类型分发至命令回复处理器; 命令回复处理器,将数据写入Socket中返回给客户端。 4.4 模型优缺点 以上流程分析我们可以看出Redis采用的是单线程Reactor模型,我们也分析了这种模式的优缺点,那Redis为什么还要采用这种模式呢? Redis本身的特性 命令执行基于内存操作,业务处理逻辑比较快,所以命令处理这一块单线程来做也能维持一个很高的性能。 优点 Reactor单线程模型的优点,参考上文。 缺点 Reactor单线程模型的缺点也同样在Redis中来体现,唯一不同的地方就在于业务逻辑处理(命令执行)这块不是系统瓶颈点。 随着流量的上涨,IO操作的的耗时会越来越明显(read操作,内核中读数据到应用程序。write操作,应用程序中的数据到内核),当达到一定阀值时系统的瓶颈就体现出来了。 Redis又是如何去解的呢? 哈哈~将耗时的点从主线程拎出来呗?那Redis的新版本是这么做的吗?我们一起来看下。 4.5 Redis多线程模式 Redis的多线程模型跟”多Reactor多线程模型“、“单Reactor多线程模型有点区别”,但同时用了两种Reactor模型的思想,具体如下; Redis的多线程模型是将IO操作多线程化,本身逻辑处理过程(命令执行过程)依旧是单线程,借助了单Reactor思想,实现上又有所区分。 将IO操作多线程化,又跟单Reactor衍生出多Reactor的思想一致,都是将IO操作从主线程中拎出来。 命令执行大致流程 客户端发送请求命令,触发读就绪事件,服务端主线程将Socket(为了简化理解成本,统一用Socket来代表连接)放入一个队列,主线程不负责读; IO 线程通过Socket读取客户端的请求命令,主线程忙轮询,等待所有 I/O 线程完成读取任务,IO线程只负责读不负责执行命令; 主线程一次性执行所有命令,执行过程和单线程一样,然后需要返回的连接放入另外一个队列中,有IO线程来负责写出(主线程也会写); 主线程忙轮询,等待所有 I/O 线程完成写出任务。 五、总结 了解一个组件,更多的是要去了解他的设计思路,要去思考为什么要这么设计,做这种技术选型的背景是啥,对后续做系统架构设计有什么参考意义等等。一通百通,希望对大家有参考意义。 作者:vivo互联网服务器团队-Wang Shaodong

优秀的个人博客,低调大师

每日一博 | JavaScript 字节单位换算函数【详解】

前言:我们在日常开发中,经常会遇到字节单位换算的场景。一般用简单的除法来计算时,都会遇到计算精度的问题。下面,我们着重要将的是一种精确字节单位换算的详解: 1. 先看代码 const byteConvert = function(bytes) { if (isNaN(bytes)) { return ''; } let symbols = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; let exp = Math.floor(Math.log(bytes)/Math.log(2)); if (exp < 1) { exp = 0; } let i = Math.floor(exp / 10); bytes = bytes / Math.pow(2, 10 * i); if (bytes.toString().length > bytes.toFixed(2).toString().length) { bytes = bytes.toFixed(2); } return bytes + ' ' + symbols[i]; }; // 函数调用 byteConvert(bytes); 复制代码 这段代码很好用,而且也是网上普遍流传的一种方法,我们下面就详细了解一下,为什么这段代码可以实现这种效果呢? 我们可以看到,这个函数中的核心运算是对数运算和指数运算,JavaScript 中的 Math.log() 函数是对数运算,需要强调的一点是,它是自然对数运算(以 e 为底)。 我们都知道,1024 = 2^10,那么进行对数运算就会变为: 接下来就需要理解这行核心代码了:let exp = Math.floor(Math.log(bytes)/Math.log(2));。理解它之前,我们需要搬出来对数运算的一个重要推论——换底公式,如下: 现在是不是很明了了,Math.log(bytes)/Math.log(2) 其实就是再求 bytes 以 2 为底的指数,我们换算一下,如下: 最后的 exp 就是我们得到的指数了。exp 为 10 时,bytes 就是 1024。所以后面的运算都是依据 10 这个单位量来计算的。i 为 1 就是最起码有一个 KB 的量级了,i 为 2 就是最起码有一个 MB 的量级,等等类推,后续做了一些保留小数位的优化等等,就不再赘述了,欢迎大家共同交流类似有趣的代码~~ 最后 如果你觉得此文对你有一丁点帮助,点个赞。或者可以加入我的开发交流群:1025263163相互学习,我们会有专业的技术答疑解惑 如果你觉得这篇文章对你有点用的话,麻烦请给我们的开源项目点点star:http://github.crmeb.net/u/defu不胜感激!

优秀的个人博客,低调大师

每日一博 | Android 资源溢出崩溃轻松解

一、资源溢出是什么? 毫无疑问,应用的运行需要占用系统的资源。其中最为人所熟知的资源是内存,内存溢出便是耳熟能详的OOM。 常见的简单OOM一般可以通过堆栈来解决,如Java OOM,一部分可以直接从堆栈中看到哪里使用了多大内存导致了内存溢出,复杂一些的Java OOM,则可以使用其他分析工具来进行处理。但如果堆栈里看不出来呢?或者它不是Java崩溃呢? java.lang.OutOfMemoryError:Failedtoallocatea3237132byteallocationwith1612328freebytesand1574KBuntilOOM 比如下面这样的Native崩溃,堆栈全是系统堆栈,不花时间去研究就很难确定此崩溃的原因(事实上这个崩溃也是一个OOM)。尤其是,我们并不能说这是系统代码的问题。 接下来本文将会介绍,对于这类崩溃如何进行识别、以及解决。 二、内存溢出(俗称OOM) 如下case: Signal6(SIGABRT),Code-1(SI_QUEUE) #00pc000604de/apex/com.android.runtime/lib/bionic/libc.so(abort+165) #01pc0003606d/system/lib/libc++.so(abort_message+88) #02pc000361f1/system/lib/libc++.so(_ZL28demangling_terminate_handlerv+160) #03pc00045e4b/system/lib/libc++.so(_ZSt11__terminatePFvvE+2) #04pc00045653/system/lib/libc++.so(_ZN10__cxxabiv1L12failed_throwEPNS_15__cxa_exceptionE+12) #05pc000455b5/system/lib/libc++.so(__cxa_throw+72) #06pc00047c6d/system/lib/libc++.so(_Znwj+52) #07pc0000132b/system/lib/libbinderthreadstate.so(_ZNSt3__15dequeIN7android18IPCThreadStateBase9CallStateENS_9allocatorIS3_EEE19__add_back_capacityEv+186) #08pc0000120b/system/lib/libbinderthreadstate.so(_ZNSt3__15dequeIN7android18IPCThreadStateBase9CallStateENS_9allocatorIS3_EEE12emplace_backIJRS3_EEES8_DpOT_+52) #09pc0000115f/system/lib/libbinderthreadstate.so(_ZN7android18IPCThreadStateBase16pushCurrentStateENS0_9CallStateE+18) #10pc0003b901/system/lib/libbinder.so(_ZN7android14IPCThreadState14executeCommandEi+608) #11pc0003b5db/system/lib/libbinder.so(_ZN7android14IPCThreadState20getAndExecuteCommandEv+98) #12pc0003bb7b/system/lib/libbinder.so(_ZN7android14IPCThreadState14joinThreadPoolEb+38) #13pc00054de5/system/lib/libbinder.so(_ZN7android10PoolThread10threadLoopEv+12) #14pc0000d96f/system/lib/libutils.so(_ZN7android6Thread11_threadLoopEPv+210) #15pc00080eb5/system/lib/libandroid_runtime.so(_ZN7android14AndroidRuntime15javaThreadShellEPv+88) #16pc000ab3dd/apex/com.android.runtime/lib/bionic/libc.so(_ZL15__pthread_startPv+20) #17pc00061989/apex/com.android.runtime/lib/bionic/libc.so(__start_thread+30) 特征很明显,堆栈全是系统代码(/system/lib/xxx)。 这时候 无法一眼看出代码问题,那么就 可以怀疑下内存原因。 2.1 崩溃原因 众所周知,32位CPU寻址范围最大可以到2的32次方 = 4GB,其实就是 32位操作系统最大支持 4G内存。 如果你试图装过系统就会明白,32位操作系统下,内存不可能达到4G以上,一般会是3G左右。 为什么是3G?因为还有 1G被系统吃掉了(不一定真的是1G,可多可少但不会差的远),它们用于操作系统内核相关的运作,如下图。 这里直接 总结重点: 32位的App在 32位的手机操作系统上使用 超过3G的内存,极大概率会发生 Native崩溃; 32位的App在 64位的手机操作系统上使用 超过4G的内存,极大概率会发生 Native崩溃; 其中前者容易理解,1G被系统吃了,就剩下了3G;后者是因为64位手机上,系统是64位的,所以不需要跟App抢那4G空间。 至于64位App,可用内存已经突破天际(所以开发64位app将会减少大量Native崩溃)…… 几种主流内存占用类型,可以文末会给出一个总结。 需要注意,这里提到的内存,均为 虚拟内存(可以回忆回忆学校学的操作系统知识,网上搜索瞅瞅)。 2.2 定位解决 这里需要用到的工具为应用性能监控全链路版(APMPlus),APMPlus是字节跳动应用开发套件MARS下的性能监控产品,通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,解决企业对各端监控的需求。具备非侵入式监控、丰富的异常现场还原能力,助力企业提升异常问题排查与解决的效率、优化应用品质,以降低成本提高收入。 经过多年技术积累、亿级用户验证,APMPlus 集崩溃监控、上报、分析、归因于一体,可以轻松定位各种线上疑难杂症,更有超详细 性能、卡顿、打点等全流程监控处理工具,覆盖近乎一切线上问题的处理。并拥有多个外部客户的实践,如:虎扑、作业帮、甄云科技等,为企业和开发者提供 一站式APM服务。 我们直接在 A PMPlus 平台中查看崩溃,点击“ Native 信息 -> Maps详情”,查看 虚拟内存占用。 一眼看出,这个内存占用明显接近上一节中提到的 内存占满的阈值(32App在64位设备上最多使用4G内存)!此时基本可以确认,该崩溃为内存占满导致的Native崩溃,即Native OOM。 知道是Native OOM就完了? 再点一个按钮,直接告诉你怎么解决:“ Native 信息 -> Maps智能归类”,查看 虚拟内存占用分布。 我们可以看到,这里直接提示出三个地方占用的虚拟内存最多,分别是Java runtime、Thread、Files;其中 Thread占用最多,高达2.59GB! 直接根据提示,逐级展开内存占用最多的条目: 立即破案:doTestThread 线程过多导致虚拟内存占满!接下来只需要去代码里看,哪里创建的这个线程,便可进行问题解决。 类似的,一旦在崩溃中发现Maps智能归类中给出的任意一个条目过高,都可以确认出Native OOM的原因;假如发现Files条目占用内存达到了2G,那么只需根据内存名即可确认什么文件占用内存多,从而进行问题定位解决。 其中由于 “ Java runtime”条目占用起点较高,其内包含Java堆内存等虚拟机自用区域,基本上固定占用1G上下,且一般情况下其占用不会受我们的代码控制,所以需要注意不要被它混淆了视线, 优先关注其他条目即可。 另外,Thread内存占用过多且需要查看线程的详细信息时,可以在“Native信息 -> 线程状态”中查看。 注:不同App下,虚拟内存分布的结果都有不同,具体分析需联系自身App正常情况下的内存分布来确认问题。 2.3 内存类型简要解释 ApmInsight平台当前的内存分类方式: Java runtime:安卓系统Java虚拟机占用,一般App默认会占用1G以上,可降低关注优先级 Native Heap:C代码使用的堆内存大小,如malloc调用分配的内存等,都会在这里体现; Thread:线程使用的内存大小,默认情况下每个线程启动后(Java、Native均如此)便会占用1M内存 Files:映射入内存中的文件,一般由C代码中调用mmap直接加载文件到内存里,Java中使用FileInputStream不会在这里体现 Devices:设备相关内存使用 nameless:部分没有名字的未知内存使用 Other:其他未识别内存 三、FD溢出 如下case: Signal6(SIGABRT),Code-1(SI_QUEUE) abortmessage:'Couldnotmakewakeeventfd:Toomanyopenfiles' #00pc000604de/apex/com.android.runtime/lib/bionic/libc.so(abort+165) #01pc00005a95/system/lib/liblog.so(__android_log_assert+176) #02pc000100bf/system/lib/libutils.so(_ZN7android6LooperC2Eb+218) #03pc000d3c51/system/lib/libandroid_runtime.so(_ZN7android18NativeMessageQueueC1Ev+112) #04pc000d424d/system/lib/libandroid_runtime.so(_ZN7androidL34android_os_MessageQueue_nativeInitEP7_JNIEnvP7_jclass+12) #05pc002920e7/system/framework/arm/boot-framework.oat(art_jni_trampoline+94) #06pc000d7bc5/apex/com.android.runtime/lib/libart.so(art_quick_invoke_stub_internal+68) ...... #30pc003afb7f/apex/com.android.runtime/lib/libart.so(_ZN3art6Thread14CreateCallbackEPv+1018) #31pc000ab3dd/apex/com.android.runtime/lib/bionic/libc.so(_ZL15__pthread_startPv+20) #32pc00061989/apex/com.android.runtime/lib/bionic/libc.so(__start_thread+30) 同样的,堆栈基本无意义,但有一句看起来能看懂的“Too many open files”。 3.1 崩溃原因 FD即文件描述符(File Descriptor),打开一个文件就占用一个。 看起来没什么的,大家读写文件都是常规操作,一个App产生千八百个文件不过分吧。 但是,系统会 限制单个App打开的 FD 个数! 该数字在部分低版本安卓机上 一般为1024,也就是你打开1024个FD后,就不能再打开了,有时候就会因此 产生Native崩溃。 3.2 定位解决 直接点开“ Native 信息 -> FD 归类”,来确认是不是 FD 过多导致的崩溃。 很明显,确实可以看到使用的FD过多,达到了3万以上。向下滚动可以直接看到App在运行时到底打开了哪些文件,只要找到打开的文件名,便能轻松解决此类崩溃。 四、总结 本文提到的两种崩溃类型,本质上都是 系统、应用资源不足下产生的。 资源不足实际上并不会直接导致崩溃,但是会 使某些系统调用返回出错,如open打开文件失败返回无效值、malloc分配内存失败返回无效值等。这些返回的无效值如果在使用时未做合理容错判断,则会 引起如空指针等这样的代码错误。 更多的崩溃问题归类及解析,将在应用性能监控全链路版(APMPlus)上及后续的文章中进行补充。 如果还未接入使用 应用性能监控全链路版(APMPlus),也可以立刻开始进行免费试用,目前 APMPlus面向新用户提供 试用30 天的限时免费服务。其中包含 App 监控、Web 监控、Server 监控、小程序 监 控,App 监控和 Web 监控各500 万条事件量, Server 与小程序监控限时不限量。

优秀的个人博客,低调大师

每日一博 | ClickHouse 存算分离架构探索

背景 ClickHouse 作为开源 OLAP 引擎,因其出色的性能表现在大数据生态中得到了广泛的应用。区别于 Hadoop 生态组件通常依赖 HDFS 作为底层的数据存储,ClickHouse 使用本地盘来自己管理数据,官方推荐使用 SSD 作为存储介质来提升性能。但受限于本地盘的容量上限以及 SSD 盘的价格,用户很难在容量、成本和性能这三者之间找到一个好的平衡。JuiceFS 的某个客户近期就遇到了这样的难题,希望将 ClickHouse 中的温冷数据从 SSD 盘迁移到更大容量、更低成本的存储介质,更好地支撑业务查询更长时间数据的需求。 JuiceFS 是基于对象存储实现并完全兼容 POSIX 的开源分布式文件系统,同时 JuiceFS 的数据缓存特性可以智能管理查询热点数据,非常适合作为 ClickHouse 的存储系统,下面将详细介绍这个方案。 MergeTree 存储格式简介 在介绍具体方案之前先简单了解一下 MergeTree 的存储格式。MergeTree 是 ClickHouse 最主要使用的存储引擎,当创建表时可以通过 PARTITION BY 语句指定以某一个或多个字段作为分区字段,数据在磁盘上的目录结构类似如下形式: $ ls -l /var/lib/clickhouse/data/<database>/<table> drwxr-xr-x 2 test test 64B Mar 8 13:46 202102_1_3_0 drwxr-xr-x 2 test test 64B Mar 8 13:46 202102_4_6_1 drwxr-xr-x 2 test test 64B Mar 8 13:46 202103_1_1_0 drwxr-xr-x 2 test test 64B Mar 8 13:46 202103_4_4_0 以 202102_1_3_0 为例,202102 是分区的名称,1 是最小的数据块编号,3 是最大的数据块编号,0 是 MergeTree 的深度。可以看到 202102 这个分区不止一个目录,这是因为 ClickHouse 每次在写入的时候都会生成一个新的目录,并且一旦写入以后就不会修改(immutable)。每一个目录称作一个「part」,当 part 逐渐变多以后 ClickHouse 会在后台对多个 part 进行合并(compaction),通常的建议是不要保留过多 part,否则会影响查询性能。 每个 part 目录内部又由很多大大小小的文件组成,这里面既有数据,也有一些元信息,一个典型的目录结构如下所示: $ ls -l /var/lib/clickhouse/data/<database>/<table>/202102_1_3_0 -rw-r--r-- 1 test test ?? Mar 8 14:06 ColumnA.bin -rw-r--r-- 1 test test ?? Mar 8 14:06 ColumnA.mrk -rw-r--r-- 1 test test ?? Mar 8 14:06 ColumnB.bin -rw-r--r-- 1 test test ?? Mar 8 14:06 ColumnB.mrk -rw-r--r-- 1 test test ?? Mar 8 14:06 checksums.txt -rw-r--r-- 1 test test ?? Mar 8 14:06 columns.txt -rw-r--r-- 1 test test ?? Mar 8 14:06 count.txt -rw-r--r-- 1 test test ?? Mar 8 14:06 minmax_ColumnC.idx -rw-r--r-- 1 test test ?? Mar 8 14:06 partition.dat -rw-r--r-- 1 test test ?? Mar 8 14:06 primary.idx 其中比较重要的文件有: primary.idx:这个文件包含的是主键信息,但不是当前 part 全部行的主键,默认会按照 8192 这个区间来存储,也就是每 8192 行存储一次主键。 ColumnA.bin:这是压缩以后的某一列的数据,ColumnA 只是这一列的代称,实际情况会是真实的列名。压缩是以 block 作为最小单位,每个 block 的大小从 64KiB 到 1MiB 不等。 ColumnA.mrk:这个文件保存的是对应的 ColumnA.bin 文件中每个 block 压缩后和压缩前的偏移。 partition.dat:这个文件包含的是经过分区表达式计算以后的分区 ID。 minmax_ColumnC.idx:这个文件包含的是分区字段对应的原始数据的最小值和最大值。 基于 JuiceFS 的存算分离方案 因为 JuiceFS 完全兼容 POSIX,所以可以把 JuiceFS 挂载的文件系统直接作为 ClickHouse 的磁盘来使用。这种方案下数据会直接写入 JuiceFS,结合为 ClickHouse 节点配置的缓存盘,查询时涉及的热数据会自动缓存在 ClickHouse 节点本地。整体方案如下图所示。 ClickHouse 在写入时会产生大量的小文件,因此如果写入压力较大这个方案对写入和查询性能都会有一定影响。建议在写入数据时增大写入缓存,尽量一次写入更多数据来避免这个小文件过多的问题。最简单的做法是使用ClickHouse 的 Buffer 表,基本上不需要修改应用代码就可以解决小文件过多的问题,适合当 ClickHouse 宕机时允许少量数据丢失的场景。这样做的好处是存储和计算完全分离,ClickHouse 节点完全无状态,如果节点故障可以很快恢复,不涉及任何数据拷贝。未来可以让 ClickHouse 感知到底层存储是共享的,实现自动的无数据拷贝迁移。 同时由于 ClickHouse 通常应用在实时分析场景,这个场景对于数据实时更新的要求比较高,在分析时也需要经常性地查询新数据。因此数据具有比较明显的冷热特征,即一般新数据是热数据,随着时间推移历史数据逐渐变为冷数据。利用 ClickHouse 的存储策略(storage policy)来配置多块磁盘,通过一定条件可以实现自动迁移冷数据到 JuiceFS。整体方案如下图所示。 这个方案中数据会先写入本地磁盘,当满足一定条件时 ClickHouse 的后台线程会异步把数据从本地磁盘迁移到 JuiceFS 上。和第一个方案一样,查询时也会自动缓存热数据。注意图中为了区分写和读因此画了两块磁盘,实际使用中没有这个限制,可以使用同一个盘。虽然这个方案不是完全的存储计算分离,但是可以满足对写入性能要求特别高的场景需求,也保留一定的存储资源弹性伸缩能力。下面会详细介绍这个方案在 ClickHouse 中如何配置。 ClickHouse 支持配置多块磁盘用于数据存储,下面是示例的配置文件: <storage_configuration> <disks> <jfs> <path>/jfs</path> </jfs> </disks> </storage_configuration> 上面的 /jfs 目录即是 JuiceFS 文件系统挂载的路径。在把以上配置添加到 ClickHouse 的配置文件中,并成功挂载 JuiceFS 文件系统以后,就可以通过 MOVE PARTITION 命令将某个 partition 移动到 JuiceFS 上,例如: ALTER TABLE test MOVE PARTITION 'xxx' TO DISK 'jfs'; 当然这种手动移动的方式只是用于测试,ClickHouse 支持通过配置存储策略的方式来将数据自动从某个磁盘移动到另一个磁盘。下面是示例的配置文件: <storage_configuration> <disks> <jfs> <path>/jfs</path> </jfs> </disks> <policies> <hot_and_cold> <volumes> <hot> <disk>default</disk> <max_data_part_size_bytes>1073741824</max_data_part_size_bytes> </hot> <cold> <disk>jfs</disk> </cold> </volumes> <move_factor>0.1</move_factor> </hot_and_cold> </policies> </storage_configuration> 上面的配置文件中有一个名为hot_and_cold的存储策略,其中定义了两个 volume,名为hot的 volume 是默认的 SSD 盘,名为cold的 volume 即是上一步disks中定义的 JuiceFS 盘。这些 volume 在配置文件中的顺序很重要,数据会首先存储到第一个 volume 中,而max_data_part_size_bytes这个配置表示当数据 part 超过指定的大小时(示例中是 1GiB)自动从当前 volume 移动到下一个 volume,也就是把数据从 SSD 盘移动到 JuiceFS。最后的move_factor配置表示当 SSD 盘的磁盘容量超过 90% 时也会触发数据移动到 JuiceFS。 最后在创建表时需要显式指定要用到的存储策略: CREATE TABLE test ( ... ) ENGINE = MergeTree ... SETTINGS storage_policy = 'hot_and_cold'; 当满足数据移动的条件时,ClickHouse 就会启动后台线程去执行移动数据的操作,默认会有 8 个线程同时工作,这个线程数量可以通过background_move_pool_size配置调整。 除了配置存储策略以外,还可以在创建表时通过TTL将超过一段时间的数据移动到 JuiceFS 上,例如: CREATE TABLE test ( d DateTime, ... ) ENGINE = MergeTree ... TTL d + INTERVAL 1 DAY TO DISK 'jfs' SETTINGS storage_policy = 'hot_and_cold'; 上面的例子是将超过 1 天的数据移动到 JuiceFS 上,结合存储策略一起可以非常灵活地管理数据的生命周期。 写入性能测试 采用冷热数据分离方案以后数据并不会直接写入 JuiceFS,而是先写入 SSD 盘,再通过后台线程异步迁移到 JuiceFS 上。但是我们希望直接评估不同存储介质在写数据的场景有多大的性能差异,因此这里在测试写入性能时没有配置冷热数据分离的存储策略,而是让 ClickHouse 直接写入不同的存储介质。 具体测试方法是将真实业务中的某一张 ClickHouse 表作为数据源,然后使用 INSERT INTO 语句批量插入千万级行数的数据,比较直接写入 SSD 盘、JuiceFS 以及对象存储的吞吐。最终的测试结果如下图: **以 SSD 盘作为基准,可以看到 JuiceFS 的写入性能与 SSD 盘有 30% 左右的性能差距,但是相比对象存储有 11 倍的性能提升。**这里 JuiceFS 的测试中开启了writeback 选项,这是因为 ClickHouse 在写入时每个 part 会产生大量的小文件(KiB 级),客户端采用异步写入的方式能明显提升性能,同时大量的小文件对于查询性能也会造成一定影响。 在了解了直接写入不同介质的性能以后,接下来测试冷热数据分离方案的写入性能。经过实际业务测试,基于 JuiceFS 的冷热数据分离方案表现稳定,因为新数据都是直接写入 SSD 盘,因此写入性能与上面测试中的 SSD 盘性能相当。SSD 盘上的数据可以很快迁移到 JuiceFS 上,在 JuiceFS 上对数据 part 进行合并也都是没有问题的。 查询性能测试 查询性能测试使用真实业务中的数据,并选取几个典型的查询场景进行测试。其中 q1-q4 是扫描全表的查询,q5-q7 是命中主键索引的查询。测试结果如下图: **可以看到 JuiceFS 与 SSD 盘的查询性能基本相当,平均差异在 6% 左右,但是对象存储相比 SSD 盘有 1.4 至 30 倍的性能下降。**得益于 JuiceFS 高性能的元数据操作以及本地缓存特性,可以自动将查询请求需要的热数据缓存在 ClickHouse 节点本地,大幅提升了 ClickHouse 的查询性能。需要注意的是以上测试中对象存储是通过 ClickHouse 的S3 磁盘类型进行访问,这种方式只有数据是存储在对象存储上,元数据还是在本地磁盘。如果通过类似 S3FS 的方式把对象存储挂载到本地,性能会有进一步的下降。 在完成基础的查询性能测试以后,接下来测试冷热数据分离方案下的查询性能。区别于前面的测试,当采用冷热数据分离方案时,并不是所有数据都在 JuiceFS 中,数据会优先写入 SSD 盘。 首先选取一个固定的查询时间范围,评估 JuiceFS 缓存对性能的影响,测试结果如下图: 跟固定时间范围的查询一样,从第二次查询开始因为缓存的建立带来了 78% 左右的性能提升。不同的地方在于第四次查询因为涉及到查询新写入或者合并后的数据,而 JuiceFS 目前不会在写入时缓存大文件,会对查询性能造成一定影响,之后会提供参数允许缓存写入数据来改善新数据的查询性能。 总结 通过 ClickHouse 的存储策略可以很简单地将 SSD 和 JuiceFS 结合使用,实现性能与成本的两全方案。从写入和查询性能测试的结果上来看 JuiceFS 完全可以满足 ClickHouse 的使用场景,用户不必再担心容量问题,在增加少量成本的情况下轻松应对未来几倍的数据增长需求。JuiceFS 目前已经支持超过 20 家公有云的对象存储,结合完全兼容 POSIX 的特性,不需要改动 ClickHouse 任何一行代码就可以轻松接入云上的对象存储。 展望 在当前越来越强调云原生的环境下,存储计算分离已经是大势所趋。ClickHouse2021 年的 roadmap 上已经明确把存储计算分离作为了主要目标,虽然目前 ClickHouse 已经支持把数据存储到 S3 上,但这个实现还比较粗糙。未来 JuiceFS 也会与 ClickHouse 社区紧密合作共同探索存算分离的方向,让 ClickHouse 更好地识别和支持共享存储,实现集群伸缩时不需要做任何数据拷贝。 推荐阅读: Elasticsearch 存储成本省 60%,稿定科技干货分享

优秀的个人博客,低调大师

每日一博 | iOS 签名校验那些事儿

导读:iOS签名校验机制是苹果生态安全的基础,日常工作中无论是开发阶段还是测试阶段常常会遇到很多需要通过签名机制解决的问题,了解iOS签名机制的原理有助于提高我们解决相关问题的成本和效率。本文首先介绍数字签名和证书的原理,之后会介绍苹果开发者证书的生成安装过程,最后对苹果实现签名校验的流程及流程中涉及的关键信息进行介绍,希望能对大家有所帮助。 全文9494字,预计阅读时间24分钟。 背景 iOS15 beta版发布后,QA同学需要测试新功能在iOS 15系统下的功能稳定性,但是通过公司流水线打出的企业包无法安装在 iOS15系统的设备上,具体报错见下图: 通过在控制台查看安装过程中的具体报错信息,发现具体的IPA包的签名信息在iOS 15系统上已经不再被支持了:The code signature version is no longer supported.详细的控制台报错信息如下图所示: 也就是安装失败和IPA的代码签名版本有关,为此通过codesign命令查看流水线产出的IPA的相关签名信息,具体信息见下图,其中CodeDirectory的版本为v=20400: CodeDirectory直译是代码目录,记录的是将Mach-O文件分页后每页的哈希值信息,除此之外还包含了资源文件的摘要信息、权限信息等,这些信息按照一定的格式组合在一起,而不同的signature version 对应这不同的信息种类、组合形式或计算算法。去年从iOS 14beta2开始,苹果系统采用一种更为安全的签名格式,在其上运行使用旧签名格式签名的APP时会出警告弹窗。其官方介绍中提到In a future release, the new format will become mandatory, and the system won’t launch apps with the old signature format.因此怀疑是签名格式在iOS 15 系统上进行了强制校验,导致安装失败: 在官方文档中提到 If you signed your app on a Mac running macOS 10.14 or later, the app already hasthe new signature format.换句话说macOS系统的版本会影响到签名的格式;"For any value of v less than 20400, you’ll need to re-sign your app." 当CodeDirectoryversion的值小于 20400时可以通过重签名来解决签名失效问题。而CodeDirectory v=20400可以安装在iOS 14系统上,但是却无法安装在iOS 15系统上。为了解决这个问题,采用重签名的思路,在macOS 11.5.2及 macOS12.0Beta 版系统下分别对流水线 IPA包重签名后,均可以正常安装在 iOS 15系统上了。 以上是iOS签名校验机制的一个体现,日常开发及测试中,与上述iOS签名校验机制相关的事情还有很多比如修改系统时间后使得企业证书过期从而导致启动崩溃等,在这里再和大家一起分享下有关iOS证书与签名校验相关的一些信息。 首先我们看几个问题: 什么是签名校验机制? 对可执行文件或脚本进行数字签名,用来保证软件在签名后未被损害或者修改的措施; 苹果为什么要使用签名校验机制? Signing your app allows iOS to identify who signed your app and to verify that your app hasn’t been modified since you signed it. 即确定APP来源并防止外部攻击,以实现苹果对其生态的控制; 苹果如何实现签名校验? 这是iOS签名校验机制的流程示意图,在学习过程中,个人觉得理解清楚非对称加密、数字签名以及数字证书的原理对于理解整个签名机制非常重要,因此在文章的开始会对加密与解密、数字签名以及数字证书做一些说明,然后结合数字签名和证书的原理,对苹果创建开发者证书的流程以及iOS签名校验流程包括过程中涉及到一些关键信息进行说明: 1. 加密与解密、数字签名、证书在通信过程中各自的作用 2. 解析苹果开发者证书的生成安装过程 3. 苹果实现签名校验的流程及关键信息 一、加密与解密、数字签名、证书在通信过程中各自的作用 无论是从AppStore下载App安装到手机上还是在Debug阶段通过Xcode将App安装到测试机上进行真机调试,其实都是一次将App包发送到手机沙盒中的一次通信过程,如何保证一次安全有效的通信呢?或者说在一次通信过程中可能面临哪些安全问题呢? A向B发送的明文,可以被中间路由直接查看,泄密了 --- 被窃听 好事者C修改了A向B请求内容,使得A发起了错误请求 --- 被篡改 好事者C拦截并模拟A向B发起请求,让B误以为是A发起的 --- 被欺骗 A向B发起了请求,但是A就是不承认发起了此次请求 --- 被否认 1.1 防止被窃听 - 加密与解密 通信过程中是不好避免被第三方拦截的,但是我们可以做到即便被截获,窃听者仍然无法破译有效的信息,这个过程可以通过加密来实现。 目前加密算法主要有两大类,对称加密和非对称加密: 对称加密的特点是加密和解密使用的同一个秘钥,其特点是速度快,常用算法有: DES 、3DES、AES ; 非对称加密的密钥分为公钥和私钥,其最重要的特性是使用公钥加密的信息,只能使用私钥解密,而使用私钥加密的信息,只能使用公钥进行解密,最常用的算法有 RSA ; 但是对称加密的密钥管理繁琐且密钥传输存在一定的安全隐患,而非对称加密算法速度比较慢,尤其是对于加密数据较大的信息时耗时更加明显。因此在实际使用过程中,最常使用的是混合加密方式: 发送方:1.对信息(明文)采用DES 密钥加密;2.使用RSA加密前面的DES 密钥信息;然后将1和2步产生的信息进行整合后传递。 接收方:接收到信息后:1.用RSA解密DES 密钥信息;2.再用RSA解密获取到的密钥信息解密密文信息;最终就可以得到我们要的信息(明文)。 1.2 防止否认 - 数字签名 因为公钥是对外公开的,A可以使用公钥加密信息计算摘要,B也可以使用公钥加密信息计算摘要,也就是所有拥有公钥的人都可能成为信息发送者,这就导致A发送消息后可以否认。前面我们有提到非对称加密的特性:使用公钥加密的信息,只能使用私钥解密,而使用私钥加密的信息,只能使用公钥进行解密。公钥是对外公开的,但是私钥仅存在于密钥对生成者自己手里,所以当在发送消息时使用发送者的私钥加密信息,在接收端使用发送者的公钥来解密信息,那么接收端就可以精确确认消息发送者了,也就解决了消息发送者否认发送的问题。使用发送者的私钥对摘要进行加密,就形成了签名,即数字签名。 我们来看下数字签名是如何起作用的: 总结下数字签名的作用,主要有三点:1.可以确认消息的完整性;2.可以识别消息是否被篡改;3.可以防止消息发送者否认; 但是我们不能忽视一点:数字签名生效的前提:用于加密的公钥必须属于真正的接受者,用于解密的公钥必须属于真正的发送者。如果公钥被伪造了或者说好事者冒充发送者去发送消息,单纯依赖数字签名是无法解决的;我们一起来看下公钥被伪造时会发生什么: 由上图可以看到,如果公钥在传输过程中被伪造了,那么后续相关的通信过程都是建立一个伪造的密钥对间进行,从而导致这个通信过程不再安全。因此在验证签名之前,首先得先验证公钥的合法性。 但是如何保证公钥的合法性呢? 根据上面的相关介绍,为了保证公钥信息不被篡改和欺骗,那么最好的方式就是对公钥进行数字签名,但问题又来了,如何保证此次数字签名验证过程中公钥的合法性呢?再来一次数字签名吗?这就陷入了一个鸡生蛋蛋生鸡的过程了,为了打破这个死循环,人们创造了证书中心和数字证书。 1.3 保证公钥的合法性 - 数字证书 循环进行数字签名的原因是因为公钥有可能被伪造,为此人们将一些具有公信力的组织或者政府部门作为证书中心,由证书中心使用自己的私钥来对公钥进行数字签名。通俗理解证书中心就是那些能够认定“公钥确实属于此人”并且能够生成数字签名的权威个人或者组织,简称CA。而由CA对公钥等信息施加数字签名后就生成了证书。 我们一起看下数字证书是如何起作用的: 消息接收者首先生成密钥对,并将公钥_B 及邮箱等信息在CA 认证机构中进行注册,认证机构CA 用自己的私钥_CA 对公钥_B 进行数字签名,并生成证书;消息发送者A向认证机构CA 请求证书,并通过认证机构CA 的公钥_CA 验证数字签名,以验证公钥_B 的合法性。 对此,我们对公钥、私钥、数字签名、数字证书的概念及作用有了一个大概的回顾。下面我们从数字签名及数字证书的生成流程角度来看下iOS开发者证书的生成与安装过程。 二、苹果开发者证书的生成与安装 -1.生成证书请求:钥匙串访问 -> 证书助理 -> 从证书颁发机构请求证书 (可以理解为Mac 端创建非对称加密密钥对公钥M 和私钥M 的过程,使用私钥M 来签署生成CSR 文件,CSR 文件中包含开发者的信息和公钥M ,私钥M 则存储在Mac端本地) -2.登录开发者平台,将第一步生成的CertificateSigningRequest.certSigningRequest上传至Developer生成证书。(CertificateSigningRequest.certSigningRequest可以理解为Mac生成的公钥,上传至开发者平台可以为将公钥M在证书认证机构CA进行注册,并利用苹果开发者中心的公钥A对Mac设备的公钥进行签名,生成开发者证书) - 3.Mac将证书下载到本地,双击安装(在安装过程中,钥匙串keychain会将第一步生成的私钥M 与第二步生成的Cer 证书关联在一起: 三、苹果实现签名校验的流程及关键信息 苹果签名校验机制的作用是要保证每一个安装到iOS 设备上的App 都是经过苹果官方授权的。为了实现上述效果,苹果采取的方案是双重签名,其大概流程可以参见下图: > 1.首先在Mac端生成一对密钥对(公钥M和私钥M),苹果也会生成一对秘钥对(公钥A和私钥A),其中公钥A预置在iOS设备上,私钥A放在苹果服务器中; > 2.开发者通过开发者后台将公钥M上传至苹果服务器,苹果服务器使用私钥A对公钥M进行签名生成证书,开发者下载该证书并安装在Mac中。安装过程中会将证书与私钥M进行关联。 > 3.在开发过程中,会使用私钥M对APP进行签名,同时会将证书也打包进APP中。 > 4.在安装过程中,iOS系统首先会使用预置的公钥A对证书进行验证,得到公钥M(验证成功,即说明证书是经过苹果私钥签名过的,根据前面的讲解我们知道这样就说明是经过苹果认证的了),然后使用公钥M对签名进行验证,验证成功后才能将APP安装在设备中。 那是不是只要拥有一个经过苹果认证的开发者证书,就可以将任意App 安装在任意测试机上呢?显然这是苹果生态所不允许的。那苹果是通过什么方式实现设备限制的呢?而且,在团队开发中,也并不是所有人都去生成开发者证书,那又是通过什么方式实现团队授权的呢?以及在苹果开发者中心中配置的BundleID、Capabilities、Devices等信息在签名机制中又扮演着什么样的作用呢? 3.1 关于P12团队协作开发 在上面的签名过程中,我们看到开发者在对App 进行签名过程中,主要用到两个信息:私钥M 和证书,因此只需要将这两个信息提供给团队中的其它成员。在证书安装过程我们了解中,钥匙串会将证书与Mac生成的私钥M 进行关联并存储为钥匙串中的一项,钥匙串支持将其导出为p12 文件,然后我们将p12 文件提供给团队成员安装就可以了。 3.2 关于Entitlements 权限管控 Xcode在编译打包时会自动执行/usr/bin/codesign命令进行代码签名,以下是签名的详细信息: 在签名时通过--sign指定了证书信息,那么--entitlements配置的是什么信息呢? entitlements链接: (https://developer.apple.com/library/archive/technotes/tn2415/_index.html) An Entitlement can be thought of as the string written into an app's signature that allows a specific capability or opts the app into a specific service. The operating system (OS) checks these strings before allowing an app access to certain features. For example, an app must have the iCloud entitlement before it is allowed to access iCloud APIs at runtime. 简单来说entitlements其实就是iOS 沙盒环境的配置文件,苹果通过entitlements文件来管控一个App 所能使用的服务和权限。Sandbox(沙盒)是iOS 安全体系中很重要的一环,沙盒不仅仅使App 间相互独立,同时也对每个App 可操作的行为进行了管控,比如可以使用哪些敏感的系统能力(Push、Sign in with apple等)等。Xcode会默认生成一个包含有Team ID 信息和App ID 信息的权限声明,如果在Xcode Signing&Capabilities中开启了相关权限,那么会显式的生成一个后缀名为.entitlements的配置文件,里面包含了相关权限信息的说明。在--entitlements选项后面的文件是在.entitlements的配置文件基础上增加了默认配置后的信息。在Xcode签名时,会将该权限信息文件嵌入到二进制代码中,作为被签名内容的一部分,由代码签名保证其不会被篡改。 可通过 security cms -D -i /path/to/iOSTest.mobileprovision 来检查本地的ProvisioningProfile是否包含所需要的权限。 3.3 关于Provisioning profile Provisioning profile act as a link between the device and the developer account. During development, you choose which devices can run your app and which app services your app can access. A provisioning profile is downloaded from your developer account and embedded in the app bundle, and the entire bundle is code-signed. ProvisioningProfile是一个由苹果证书中心加密签名的一个plist文件,包含有与之绑定的App ID、设备的UUID‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍列表、过期时间、TeamID、entilements等信息以及用于对应用程序进行签名的证书,是苹果用来解决对设备授权以及管控APP敏感权限的解决方案。其在证书中心的配置页面如下: 在证书中心配置完App ID、Unique Device Identifiers以及capabilities等信息后,苹果证书中心会将这些信息与证书一起使用苹果的私钥进行签名,最后将签名信息与配置信息、证书、签名信息一起组成一个ProvisioningProfile文件。开发者将其下载并安装,默认情况下Xcode会自动帮助开发者进行管理。以下是Xcode中安装的ProvisioningProfile信息: 在Mac编译完一个App 后,会将ProvisioningProfile文件也打包进App 中,并将文件命名为embedded.mobileprovision。在App 安装到iOS 设备上时,iOS 设备会通过预置的公钥信息来验证embedded.mobileprovision的签名合法性,进而验证证书中的签名是否正确。 确保了embedded.mobileprovision 里的数据都是苹果授权以后,就可以使用其中的信息来校验本次安装的合法性,使用公钥A验证Mach-O 的签名信息、验证证书的签名及有效期、验证设备ID是否在设备列表中、ProvisioningProfile中的App ID是否与BundleID是否匹配等等; 值得注意的一点是:设备列表只在Development证书下生效,因为Enterprise 、Distribution 证书本身就是要求可以任意安装,所以不受设备列表的限制;过期时间只对Development、Enterprise 证书生效,Distribution 证书下不受限制,这也是当Development、Enterprise 证书过期后会导致应用无法安装或无法启动,而从App Store下载的应用不会有时间限制。 当然在开发者中心可以随时对ProvisioningProfile进行修改,更新不会对已有ProvisioningProfile产生影响。 3.4 关于可执行文件与资源的签名 在编译生成App 的过程中,Xcode会通过钥匙串找到与配置证书配对的私钥M对二进制文件进行签名,并将签名信息嵌入到可执行二进制文件中,用于在安装时确认Mach-O 文件是否有被修改。可通过MackOView 验证: 但是App 中不仅仅包含二进制文件,还包含一系列诸如图片、音视频文件等预置资源,因此通过单一的对二进制文件进行签名不足以保证整个App 文件的完整性和有效性。在iOS 中,应用程序使用遵循shallow application bundles结构的目录来存储必要资源和数据信息,大致为: TestProject.app |用户可见应用程序 Info.plist |文件,其中包含有关软件包的信息 TestProject |可执行二进制 Assets.car |图标,显示为用户可见应用程序 Framework |任何可执行文件使用的框架或动态库 AFNetworking.framework |iOS 软件中常见框架示例 以下是一个非常简单的IPA包的包结构: 可以看到为了保证可执行文件及其在运行期间所依赖的数据和资源不被篡改,在实际生成的APP包中,会新生成一个_CodeSignature 目录,在该目录下包含一个名为CodeResources 文件,该文件为plist 类型的文件,里面包含了App 下除可执行文件外其它文件的信息,key 通常为文件名,value 通常为其摘要信息。里面会有files 和files2 两项,其中files保存的是每个文件的sha1的值,files2同时保存了sha1和sha256,主要原因是sha1存在不安全性。_CodeSignature/CodeResources 文件的主要作用是记录签名后每个文件的哈希值,以在安装时用来确保对应的文件没有被篡改过。 3.5 签名及校验流程 > 1.首先在Mac 端生成一对密钥对(公钥M和私钥M),苹果系统本身也会生成一对密钥对(公钥A和私钥A),其中公钥A预置在iOS 设备上,私钥A放在苹果服务器中; > 2.开发者将公钥M整合在csr 文件中通过开发者后台上传至苹果服务器,苹果服务器使用私钥A对公钥M进行数字签名生成证书;同时使用私钥A将证书、AppID、entilements、设备UDIDs等信息加密生成ProvisioningProfile 文件;对应的开发者下载证书和ProvisioningProfile 文件并安装在Mac 中。安装过程中会将证书与私钥M进行关联,Xcode 默认会对ProvisioningProfile 文件进行管理。 > 3.在开发过程中,会使用私钥M对App 进行签名,同时会将ProvisioningProfile 文件也打包进App 中即embedded.mobileprovision文件。 > 4.当将iOS 设备与Xcode 连接并通过身份验证后,Xcode 会将App 数据传输到iOS 设备中,iOS 设备此时并不会创建真正的沙盒环境来运行App ,而是会在一个临时沙盒环境中来存放陆续收到的数据,一旦全部接收完,iOS 系统首先会使用预置的公钥A对embedded.mobileprovision文件的签名进行验证,已确认App 来源是否合法;来源合法性验证通过后,便可使用公钥M去验证里面证书的签名信息、验证该设备ID是否在对应UUID 列表中、以及APP中的权限开关配置是否和embedded.mobileprovision文件中配置的entilements信息相吻合等;同时会读取_CodeSignature/CodeResources 中的信息对资源文件进行校验。只有全部经过验证后,才会为App 创建真正的沙盒环境进行安装。在启动APP时,系统也会执行相应的启动校验,以防止安装后进行的修改。 参考文献: 1.https://developer.apple.com/documentation/xcode/using-the-latest-code-signature-format 2.https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Introduction/Introduction.html 3.https://pewpewthespells.com/blog/migrating_code_signing.html 4.http://blog.cnbang.net/tech/3386/ 5.https://developer.apple.com/library/archive/recipes/ProvisioningPortal_Recipes/DownloadingaProvisioningProfile/DownloadingaProvisioningProfile.html 6.https://developer.apple.com/library/archive/technotes/tn2415/_index.html 7.https://developer.apple.com/library/archive/qa/qa1814/_index.html#//apple_ref/doc/uid/DTS40014030 8.https://getupdraft.com/blog/ios-code-signing-development-and-distribution-prov 9.https://abhimuralidharan.medium.com/what-is-a-provisioning-profile-in-ios-77987a7c54c2 推荐阅读: |信息流推荐系统智能交付解决方案探索 |图谱相关技术在风控反作弊中的应用和探索 |好看视频Android重构——围绕于播放器的重构实践 ---------- END ---------- 百度 Geek 说 百度官方技术公众号上线啦! 技术干货 · 行业资讯 · 线上沙龙 · 行业大会 招聘信息 · 内推信息 · 技术书籍 · 百度周边 欢迎各位同学关注

优秀的个人博客,低调大师

每日一博 | 基于 Pytorch 的动态卷积复现

【GaintPandaCV导语】 最近动态卷积开始有人进行了研究,也有不少的论文发表(动态卷积论文合集https://github.com/kaijieshi7/awesome-dynamic-convolution),但是动态卷积具体的实现代码却很少有文章给出。本文以微软发表在CVPR2020上面的文章为例,详细的讲解了动态卷积实现的难点以及如何动分组卷积巧妙的解决。希望能给大家以启发。 这篇文章也同步到知乎平台,链接为:https://zhuanlan.zhihu.com/p/208519425 论文的题目为《Dynamic Convolution: Attention over Convolution Kernels》 paper的地址arxiv.org/pdf/1912.0345 代码实现地址,其中包含一维,二维,三维的动态卷积;分别可以用于实现eeg的处理,正常图像的处理,医疗图像中三维脑部的处理等等(水漫金山)。github.com/kaijieshi7/D,大家觉得有帮助的话,可以点个星星。 一句话描述下文的内容:将的大小视为分组卷积里面的组的大小进行动态卷积。如,那么就转化成,的分组卷积。 简单回顾 这篇文章主要是改进传统卷积,让每层的卷积参数在推理的时候也是随着输入可变的,而不是传统卷积中对任何输入都是固定不变的参数。(由于本文主要说明的是代码如何实现,所以推荐给大家一个讲解论文的连接:Happy:动态滤波器卷积|DynamicConv) 推理的时候:红色框住的参数是固定的,黄色框住的参数是随着输入的数据不断变化的。 对于卷积过程中生成的一个特征图,先对特征图做几次运算,生成个和为的参数,然后对个卷积核参数进行线性求和,这样推理的时候卷积核是随着输入的变化而变化的。(可以看看其他的讲解文章,本文主要理解怎么写代码) 下面是attention代码的简易版本,输出的是[,]大小的加权参数。对应着要被求和的卷积核数量。 class attention2d(nn.Module): def __init__(self, in_planes, K,): super(attention2d, self).__init__() self.avgpool = nn.AdaptiveAvgPool2d(1) self.fc1 = nn.Conv2d(in_planes, K, 1,) self.fc2 = nn.Conv2d(K, K, 1,) def forward(self, x): x = self.avgpool(x) x = self.fc1(x) x = F.relu(x) x = self.fc2(x).view(x.size(0), -1) return F.softmax(x, 1) 下面是文章中个卷积核求和的公式。 其中是输入,是输出;可以看到进行了两次运算,一次用于求注意力的参数(用于生成动态的卷积核),一次用于被卷积。 但是,写代码的时候如果直接将个卷积核求和,会出现问题。接下来我们先回顾一下Pytorch里面的卷积参数,然后描述一下可能会出现的问题,再讲解如何通过分组卷积去解决问题。 Pytorch卷积的实现 我会从维度的视角回顾一下Pytorch里面的卷积的实现(大家也可以手写一下,几个重点:输入维度、输出维度、正常卷积核参数维度、分组卷积维度、动态卷积维度、attention模块输出维度)。 输入:输入数据维度大小为[,,,]。 输出:输出维度为[,,,]。 卷积核:正常卷积核参数维度为[,,,]。(在Pytorch中,2d卷积核参数应该是固定这种维度的) 这里我们可以注意到,正常卷积核参数的维度是不存在的。因为对于正常的卷积来说,不同的输入数据,使用的是相同的卷积核,卷积核的数量与一次前向运算所输入的大小无关(相同层的卷积核参数只需要一份)。 可能会出现的问题 这里描述一下实现动态卷积代码的过程中可能因为大于1而出现的问题。 对于图中attention模块最后softmax输出的个数,他们的维度为[,,,],可以直接.view成[,],紧接着作用于卷积核参数上(形成动态卷积)。 问题所在:正常卷积,一次输入多个数据,他们的卷积核参数是一样的,所以只需要一份网络参数即可;但是对于动态卷积而言,每个输入数据用的都是不同的卷积核,所以需要份网络参数,不符合Pytorch里面的卷积参数格式,会出错。 看下维度运算[,]*[,,,,],生成的动态卷积核是[,,,,],不符合Pytorch里面的规定,不能直接参与运算(大家可以按照这个思路写个代码看看,体会一下,光看可能感觉不出来问题),最简单的解决办法就是等于1,不会出现错误,但是慢啊!!! 总之,大于1会导致中间卷积核参数不符合规定。 分组卷积以及如何通过分组卷积实现大于1的动态卷积 一句话描述分组卷积:对于多通道的输入,将他们分成几部分各自进行卷积,结果concate。 组卷积过程用废话描述:对于输入的数据[,,,],假设为,那么分组卷积就是将他分为两个为的数据(也可以用其他方法分),那么维度就是[, 5x2,,],换个维度换下视角,[,,,],那么为2的组卷积可以看成的正常卷积。(如果还是有点不了解分组卷积,可以阅读其他文章仔细了解一下。) 巧妙的转换:上面将翻倍即可将分组卷积转化成正常卷积,那么反向思考一下,将变为1,是不是可以将正常卷积变成分组卷积? 我们将大小看成分组卷积中的数量,令所在维度直接变为!!!直接将输入数据从[,,,]变成[1,,,],就可以用分组卷积解决问题了!!! 详细描述实现过程:将输入数据的维度看成[1,,,](分组卷积的节奏);卷积权重参数初始化为[,,,,],attention模块生成的维度为[,],直接进行正常的矩阵乘法[,]*[,***]生成动态卷积的参数,生成的动态卷积权重维度为[,,,,],将其看成分组卷积的权重[,,,](过程中包含reshape)。这样的处理就完成了,输入数据[,,,],动态卷积核[,,,],直接是的分组卷积,问题解决。 具体代码如下: class Dynamic_conv2d(nn.Module): def __init__(self, in_planes, out_planes, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True, K=4,): super(Dynamic_conv2d, self).__init__() assert in_planes%groups==0 self.in_planes = in_planes self.out_planes = out_planes self.kernel_size = kernel_size self.stride = stride self.padding = padding self.dilation = dilation self.groups = groups self.bias = bias self.K = K self.attention = attention2d(in_planes, K, ) self.weight = nn.Parameter(torch.Tensor(K, out_planes, in_planes//groups, kernel_size, kernel_size), requires_grad=True) if bias: self.bias = nn.Parameter(torch.Tensor(K, out_planes)) else: self.bias = None def forward(self, x):#将batch视作维度变量,进行组卷积,因为组卷积的权重是不同的,动态卷积的权重也是不同的 softmax_attention = self.attention(x) batch_size, in_planes, height, width = x.size() x = x.view(1, -1, height, width)# 变化成一个维度进行组卷积 weight = self.weight.view(self.K, -1) # 动态卷积的权重的生成, 生成的是batch_size个卷积参数(每个参数不同) aggregate_weight = torch.mm(softmax_attention, weight).view(-1, self.in_planes, self.kernel_size, self.kernel_size) if self.bias is not None: aggregate_bias = torch.mm(softmax_attention, self.bias).view(-1) output = F.conv2d(x, weight=aggregate_weight, bias=aggregate_bias, stride=self.stride, padding=self.padding, dilation=self.dilation, groups=self.groups*batch_size) else: output = F.conv2d(x, weight=aggregate_weight, bias=None, stride=self.stride, padding=self.padding, dilation=self.dilation, groups=self.groups * batch_size) output = output.view(batch_size, self.out_planes, output.size(-2), output.size(-1)) return output 完整的代码在github.com/kaijieshi7/D,大家觉得有帮助的话,求点个星星。 纸上得来终觉浅,绝知此事要躬行。试下代码,方能体会其中妙处。 对文章有疑问或者想加入交流群,欢迎添加BBuf微信 为了方便各位获取公众号获取资料,可以加入QQ群获取资源,更欢迎分享资源 本文分享自微信公众号 - GiantPandaCV(BBuf233)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | Redis GeoHash 核心原理解析

1. 引言 小麦同学是个吃货+技术宅,平日里就喜欢拿着手机地图点点按按来查询一些好玩的东西。某一天到北海公园游玩,肚肚饿了,于是乎打开手机地图,搜索北海公园附近的餐馆,并选了其中一家用餐。饱暖思yin欲的麦叔饭后思考地图后台如何根据自己所在位置查询来查询附近餐馆的呢?苦思冥想了半天,小麦想出了个方法:计算所在位置P与北京所有餐馆的距离,然后返回距离<=1000米的餐馆。小得意了一会儿,小麦发现北京的餐馆何其多啊,这样计算不得了,于是想了,既然知道经纬度了,那它应该知道自己在西城区,那应该计算所在位置P与西城区所有餐馆的距离啊,机机运用了递归的思想,想到了西城区也很多餐馆啊,应该计算所在位置P与所在街道所有餐馆的距离,这样计算量又小了,效率也提升了。 小麦的计算思想很朴素,就是通过过滤的方法来减小参与计算的餐馆数目,从某种角度上讲,机机在使用索引技术。 一提到索引,大家脑子里马上浮现出B树索引,因为大量的数据库(如MySQL、oracle、PostgreSQL等)都在使用B树。B树索引本质上是对索引字段进行排序,然后通过类似二分查找的方法进行快速查找,即它要求索引的字段是可排序的,一般而言,可排序的是一维字段,比如时间、年龄、薪水等等。但是对于空间上的一个点(二维,包括经度和纬度),如何排序呢?又如何索引呢?解决的方法很多,下文介绍一种方法来解决这一问题。 思想:如果能通过某种方法将二维的点数据转换成一维的数据,那样不就可以继续使用B树索引了嘛。那这种方法真的存在嘛,答案是肯定的。目前很火的GeoHash算法就是运用了上述思想,下面我们就开始GeoHash之旅吧。 2. 感性认识 先来两个干货,在线查看GPS某个区域的GeoHash值。 1. http://geohash.gofreerange.com/ 在这里插入图片描述 2. http://www.geohash.cn/ 跟更好用些 3. 通俗说 GeoHash将二维的经纬度转换成字符串,比如下图展示了北京9个区域的GeoHash字符串,分别是WX4ER,WX4G2、WX4G3等等,每一个字符串代表了某一矩形区域。也就是说,这个矩形区域内所有的点(经纬度坐标)都共享相同的GeoHash字符串,这样既可以保护隐私(只表示大概区域位置而不是具体的点),又比较容易做缓存,比如左上角这个区域内的用户不断发送位置信息请求餐馆数据,由于这些用户的GeoHash字符串都是WX4ER,所以可以把WX4ER当作key,把该区域的餐馆信息当作value来进行缓存,而如果不使用GeoHash的话,由于区域内的用户传来的经纬度是各不相同的,很难做缓存。字符串越长,表示的范围越精确。如图所示,5位的编码能表示10平方千米范围的矩形区域,而6位编码能表示更精细的区域(约0.34平方千米)字符串相似的表示距离相近(特殊情况后文阐述),这样可以利用字符串的前缀匹配来查询附近的POI信息。如下两个图所示,第一个在城区,第二个在郊区,城区的GeoHash字符串之间比较相似,郊区的字符串之间也比较相似,而城区和郊区的GeoHash字符串相似程度要低些。通过上面的介绍我们知道了GeoHash就是一种将经纬度转换成字符串的方法,并且使得在大部分情况下,字符串前缀匹配越多的距离越近,回到我们的案例,根据所在位置查询来查询附近餐馆时,只需要将所在位置经纬度转换成GeoHash字符串,并与各个餐馆的GeoHash字符串进行前缀匹配,匹配越多的距离越近。 4. GeoHash算法的步骤 下面以北海公园附近随便一个位置为例介绍GeoHash算法的计算步骤,先用百度 GPS反定位系统查找看下经纬度。纬度=116.395371,经度=39.931957。 根据经纬度计算GeoHash二进制编码 地球纬度区间是[-90,90], 北海公园的纬度是39.928167,可以通过下面算法对纬度39.928167进行逼近编码: 区间[-90,90]进行二分为[-90,0),[0,90],称为左右区间,可以确定39.928167属于右区间[0,90],给标记为1; 接着将区间[0,90]进行二分为 [0,45),[45,90],可以确定39.928167属于左区间 [0,45),给标记为0; 递归上述过程39.928167总是属于某个区间[a,b]。随着每次迭代区间[a,b]总在缩小,并越来越逼近39.928167; 如果给定的纬度x(39.928167)属于左区间,则记录0,如果属于右区间则记录1,这样随着算法的进行会产生一个序列1011100,序列的长度跟给定的区间划分次数有关。 39.928167 根据纬度算编码 bit min mid max 1 -90.000 0.000 90.000 0 0.000 45.000 90.000 1 0.000 22.500 45.000 1 22.500 33.750 45.000 1 33.750 39.375 45.000 0 39.375 42.188 45.000 0 39.375 40.7815 42.188 0 39.375 40.07825 40.7815 1 39.375 39.726625 40.07825 1 39.726625 39.9024375 40.07825 同理,地球经度区间是[-180,180],可以对经度116.389550进行编码。根据经度算编码 bit min mid max 1 -180 0.000 180 1 0.000 90 180 0 90 135 180 1 90 112.5 135 0 112.5 123.75 135 0 112.5 118.125 123.75 1 112.5 115.3125 118.125 0 115.3125 116.71875 118.125 1 115.3125 116.015625 116.71875 1 116.015625 116.3671875 116.71875 组码 通过上述计算,纬度产生的编码为10111 00011,经度产生的编码为11010 01011。偶数位放经度,奇数位放纬度,把2串编码组合生成新串:11100 11101 00100 01111。最后使用用0-9、b-z(去掉a, i, l, o)这32个字母进行base32编码,首先将11100 11101 00100 01111转成十进制,对应着28、29、4、15,十进制对应的编码就是wx4g。同理,将编码转换成经纬度的解码算法与之相反,具体不再赘述。 5. GeoHash Base32编码长度与精度 可以看出,当geohash base32编码长度为8时,精度在19米左右,而当编码长度为9时,精度在2米左右,编码长度需要根据数据情况进行选择。 一、经纬度距离换算 在纬度相等的情况下: 经度每隔0.00001度,距离相差约1米; 每隔0.0001度,距离相差约10米; 每隔0.001度,距离相差约100米; 每隔0.01度,距离相差约1000米; 每隔0.1度,距离相差约10000米。 在经度相等的情况下: 纬度每隔0.00001度,距离相差约1.1米; 每隔0.0001度,距离相差约11米; 每隔0.001度,距离相差约111米; 每隔0.01度,距离相差约1113米; 每隔0.1度,距离相差约11132米。 6. GeoHash算法 上文讲了GeoHash的计算步骤,仅仅说明是什么而没有说明为什么?为什么分别给经度和维度编码?为什么需要将经纬度两串编码交叉组合成一串编码?本节试图回答这一问题。 如下图所示,我们将二进制编码的结果填写到空间中,当将空间划分为四块时候,编码的顺序分别是左下角00,左上角01,右下脚10,右上角11,也就是类似于Z的曲线,当我们递归的将各个块分解成更小的子块时,编码的顺序是自相似的(分形),每一个子快也形成Z曲线,这种类型的曲线被称为Peano空间填充曲线。 这种类型的空间填充曲线的优点是将二维空间转换成一维曲线(事实上是分形维),对大部分而言,编码相似的距离也相近, 但Peano空间填充曲线最大的缺点就是突变性,有些编码相邻但距离却相差很远,比如0111与1000,编码是相邻的,但距离相差很大。 除Peano空间填充曲线外,还有很多空间填充曲线,如图所示,其中效果公认较好是Hilbert空间填充曲线,相较于Peano曲线而言,Hilbert曲线没有较大的突变。为什么GeoHash不选择Hilbert空间填充曲线呢?可能是Peano曲线思路以及计算上比较简单吧,事实上,Peano曲线就是一种四叉树线性编码方式。 7. 使用注意点 1. 临界问题 由于GeoHash是将区域划分为一个个规则矩形,并对每个矩形进行编码,这样在查询附近POI信息时会导致以下问题,比如红色的点是我们的位置,绿色的两个点分别是附近的两个餐馆,但是在查询的时候会发现距离较远餐馆的GeoHash编码与我们一样(因为在同一个GeoHash区域块上),而较近餐馆的GeoHash编码与我们不一致。这个问题往往产生在边界处。 解决的思路很简单,我们查询时,除了使用定位点的GeoHash编码进行匹配外,还使用周围8个区域的GeoHash编码,这样可以避免这个问题。 2. 注意点 我们已经知道现有的GeoHash算法使用的是Peano空间填充曲线,这种曲线会产生突变,造成了编码虽然相似但距离可能相差很大的问题,因此在查询附近餐馆时候,首先筛选GeoHash编码相似的POI(point of interest)点,然后进行实际距离计算。 3. 使用心得 GeoHash只是空间索引的一种方式,特别适合点数据,而对线、面数据采用R树索引更有优势(可为什么需要空间索引)。 GeoHash值可以区分精度,位数越多,精度越高,表达的地理位置越精细;如一位的GeoHash值把地球划分为32个矩形,8位的geohash值把地球划分为32^8个小矩形 适合根据某个经纬度坐标position计算出GeoHash值,然后和数据库中精度更高的GeoHash值做前缀比较 8.空间索引 常见问题:如何根据自己所在位置查询来查询附近50米的POI(point of interest,比如商家、景点等)呢(图1a)?每个POI都有经纬度信息,用图1b的SQL语句在mySQL中建立了POI_spatial的表,其中lat和lng两个字段来代表纬度和经度。为后续分析方便起见,我人造了40万个POI数据。 方法一:暴力方法 该方法的思路很直接:计算位置与所有POI的距离,并保留距离小于50米的POI。 插句题外话,计算经纬度之间的距离不能像求欧式距离那样平方开根号,因为地球是个不规整的球体(图2a),普通计算适合都是默认按最简单的完美球体假设,两点之间的距离函数应该如图2b所示。 该方法的复杂度为:40万*距离函数。我们将球体距离函数写为mysql存储过程distance,之后我们执行查询操作(图3),发现花费了4.66秒。 方法二:矩形过滤方法 该方法采用逐步细化的方式,一般分为两部: 先用矩形框过滤(图4a),判断一个点在矩形框内很简单,只要进行两次判断(LtMin<lat<LtMax; LnMin<lng<LnMax),落在矩形框内的POI个数为n(n<<40万); 用球面距离公式计算位置与矩形框内n个POI的距离(图4b),并保留距离小于50米的POI 矩形过滤方法的复杂度:40万矩形过滤函数 + n距离函数(n<<40万)。 根据这个思路我们执行SQl查询(图5)(注:经度或纬度每隔0.001度,距离相差约100米,由此推算出矩形左下角和右上角坐标),发现过滤后正好剩下两个POI。 此查询花费了0.36秒,相比于方法一查询时间大大降低,但是对于一次查询来说还是很长。时间长的原因在于遍历了40万次。 方法三:B树对经度或纬度建立索引 方法二耗时的原因在于执行了遍历操作,为了不进行遍历,我们自然想到了索引。我们对纬度进行了B树索引。 altertablepoi_spatialaddindexlatindex(lat);altertablepoi_spatialaddindexlngindex(lng); 此方法包括三个步骤: 通过B树快速找到某纬度范围的POI(图6a),个数为m(m<40万),复杂度为Log(40万)*过滤函数; 在步骤a过滤得到的m个POI中查找某经度范围的POI(图6b),个数为n(n<m),复杂度为m*过滤函数; 用球面距离公式计算位置与步骤b得到的n个POI的距离(图6c),并保留距离小于50米的POI 执行SQL查询(图7),发现时间已经大大降低,从方法2的0.36秒下降到0.01秒 三、B树能索引空间数据吗? 这时候有人会说了:方法三效果如此好,能够满足我们附近POI查询问题啊,看来B树用来索引空间数据也是可以的嘛!那么B树真的能够索引空间数据吗? 只能对经度或纬度索引(一维索引),与期望的不符 我们期待的是快速找出落在某一空间范围的POI(如矩形)(图8a),而不是快速找出落在某纬度或经度范围的POI(图8b),想象一下,我要查询北京某区的POI,但是B树索引不仅给我找出了北京的,还有与北京同一维度的天津、大同、甚至国外城市的POI,当数据量很大时,效率很低。 当数据是多维,比如三维(x,y,z),B树怎么索引?比如z可能是高程值,也可能是时间。有人会说B树其实可以对多个字段进行索引,但这时需要指定优先级,形成一个组合字段,而空间数据在各个维度方向上不存在优先级,我们不能说纬度比经度更重要,也不能说纬度比高程更重要。 当空间数据不是点,而是线(道路、地铁、河流等),面(行政区边界、建筑物等),B树怎么索引?对于面来说,它由一系列首尾相连的经纬度坐标点组成,一个面可能有成百上千个坐标,这时数据库怎么存储,B树怎么索引,这些都是问题。 既然传统的索引不能很好的索引空间数据,我们自然需要一种方法能对空间数据进行索引,即空间索引。 参考 Java实现GPS范围查找 浙大大佬通俗说GPS 本文分享自微信公众号 - sowhat1412(sowhat9094)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | 如何优雅地记录操作日志?

操作日志广泛存在于各个B端和一些C端系统中,比如:客服可以根据工单的操作日志快速知道哪些人对这个工单做了哪些操作,进而快速地定位问题。操作日志和系统日志不一样,操作日志必须要做到简单易懂。所以如何让操作日志不和业务逻辑耦合,如何让操作日志的内容易于理解,让操作日志的接入更加简单?上面这些都是本文要回答的问题,主要围绕着如何“优雅”地记录操作日志展开描述。 1. 操作日志的使用场景 系统日志和操作日志的区别 系统日志:系统日志主要是为开发排查问题提供依据,一般打印在日志文件中;系统日志的可读性要求没那么高,日志中会包含代码的信息,比如在某个类的某一行打印了一个日志。 操作日志:主要是对某个对象进行新增操作或者修改操作后记录下这个新增或者修改,操作日志要求可读性比较强,因为它主要是给用户看的,比如订单的物流信息,用户需要知道在什么时间发生了什么事情。再比如,客服对工单的处理记录信息。 操作日志的记录格式大概分为下面几种: 单纯的文字记录,比如:2021-09-16 10:00 订单创建。 简单的动态的文本记录,比如:2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号“NO.11089999”。 修改类型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用户小明修改了订单的配送地址:从“金灿灿小区”修改到“银盏盏小区” ,其中涉及变量配送的原地址“金灿灿小区”和新地址“银盏盏小区”。 修改表单,一次会修改多个字段。 2. 实现方式 2.1 使用 Canal 监听数据库记录操作日志 Canal 是一款基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费的开源组件,通过采用监听数据库 Binlog 的方式,这样可以从底层知道是哪些数据做了修改,然后根据更改的数据记录操作日志。 这种方式的优点是和业务逻辑完全分离。缺点也很明显,局限性太高,只能针对数据库的更改做操作日志记录,如果修改涉及到其他团队的 RPC 的调用,就没办法监听数据库了,举个例子:给用户发送通知,通知服务一般都是公司内部的公共组件,这时候只能在调用 RPC 的时候手工记录发送通知的操作日志了。 2.2 通过日志文件的方式记录 log.info("订单创建") log.info("订单已经创建,订单编号:{}", orderNo) log.info("修改了订单的配送地址:从“{}”修改到“{}”, "金灿灿小区", "银盏盏小区") 这种方式的操作记录需要解决三个问题。 问题一:操作人如何记录 借助 SLF4J 中的 MDC 工具类,把操作人放在日志中,然后在日志中统一打印出来。首先在用户的拦截器中把用户的标识 Put 到 MDC 中。 @Component public class UserInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //获取到用户标识 String userNo = getUserNo(request); //把用户 ID 放到 MDC 上下文中 MDC.put("userId", userNo); return super.preHandle(request, response, handler); } private String getUserNo(HttpServletRequest request) { // 通过 SSO 或者Cookie 或者 Auth信息获取到 当前登陆的用户信息 return null; } } 其次,把 userId 格式化到日志中,使用 %X{userId} 可以取到 MDC 中用户标识。 <pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern> 问题二:操作日志如何和系统日志区分开 通过配置 Log 的配置文件,把有关操作日志的 Log 单独放到一日志文件中。 //不同业务日志记录到不同的文件 <appender name="businessLogAppender" class="ch.qos.logback.core.rolling.RollingFileAppender"> <File>logs/business.log</File> <append>true</append> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>INFO</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/业务A.%d.%i.log</fileNamePattern> <maxHistory>90</maxHistory> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>10MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> <encoder> <pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern> <charset>UTF-8</charset> </encoder> </appender> <logger name="businessLog" additivity="false" level="INFO"> <appender-ref ref="businessLogAppender"/> </logger> 然后在 Java 代码中单独的记录业务日志。 //记录特定日志的声明 private final Logger businessLog = LoggerFactory.getLogger("businessLog"); //日志存储 businessLog.info("修改了配送地址"); 问题三:如何生成可读懂的日志文案 可以采用 LogUtil 的方式,也可以采用切面的方式生成日志模板,后续内容将会进行介绍。这样就可以把日志单独保存在一个文件中,然后通过日志收集可以把日志保存在 Elasticsearch 或者数据库中,接下来看下如何生成可读的操作日志。 2.3 通过 LogUtil 的方式记录日志 LogUtil.log(orderNo, "订单创建", "小明")模板 LogUtil.log(orderNo, "订单创建,订单号"+"NO.11089999", "小明") String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”" LogUtil.log(orderNo, String.format(tempalte, "小明", "金灿灿小区", "银盏盏小区"), "小明") 这里解释下为什么记录操作日志的时候都绑定了一个 OrderNo,因为操作日志记录的是:某一个“时间”“谁”对“什么”做了什么“事情”。当查询业务的操作日志的时候,会查询针对这个订单的的所有操作,所以代码中加上了 OrderNo,记录操作日志的时候需要记录下操作人,所以传了操作人“小明”进来。 上面看起来问题并不大,在修改地址的业务逻辑方法中使用一行代码记录了操作日志,接下来再看一个更复杂的例子: private OnesIssueDO updateAddress(updateDeliveryRequest request) { DeliveryOrder deliveryOrder = deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo()); // 更新派送信息,电话,收件人,地址 doUpdate(request); String logContent = getLogContent(request, deliveryOrder); LogUtils.logRecord(request.getOrderNo(), logContent, request.getOperator); return onesIssueDO; } private String getLogContent(updateDeliveryRequest request, DeliveryOrder deliveryOrder) { String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”"; return String.format(tempalte, request.getUserName(), deliveryOrder.getAddress(), request.getAddress); } 可以看到上面的例子使用了两个方法代码,外加一个 getLogContent 的函数实现了操作日志的记录。当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁杂,最后导致 LogUtils.logRecord() 方法的调用存在于很多业务的代码中,而且类似 getLogContent() 这样的方法也散落在各个业务类中,对于代码的可读性和可维护性来说是一个灾难。下面介绍下如何避免这个灾难。 2.4 方法注解实现操作日志 为了解决上面问题,一般采用 AOP 的方式记录日志,让操作日志和业务逻辑解耦,接下来看一个简单的 AOP 日志的例子。 @LogRecord(content="修改了配送地址") public void modifyAddress(updateDeliveryRequest request){ // 更新派送信息 电话,收件人、地址 doUpdate(request); } 我们可以在注解的操作日志上记录固定文案,这样业务逻辑和业务代码可以做到解耦,让我们的业务代码变得纯净起来。可能有同学注意到,上面的方式虽然解耦了操作日志的代码,但是记录的文案并不符合我们的预期,文案是静态的,没有包含动态的文案,因为我们需要记录的操作日志是: 用户%s修改了订单的配送地址,从“%s”修改到“%s”。接下来,我们介绍一下如何优雅地使用 AOP 生成动态的操作日志。 3. 优雅地支持 AOP 生成动态的操作日志 3.1 动态模板 一提到动态模板,就会涉及到让变量通过占位符的方式解析模板,从而达到通过注解记录操作日志的目的。模板解析的方式有很多种,这里使用了 SpEL(Spring Expression Language,Spring表达式语言)来实现。我们可以先写下期望的记录日志的方式,然后再看下能否实现这样的功能。 @LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”") public void modifyAddress(updateDeliveryRequest request, String oldAddress){ // 更新派送信息 电话,收件人、地址 doUpdate(request); } 通过 SpEL 表达式引用方法上的参数,可以让变量填充到模板中达到动态的操作日志文本内容。 但是现在还有几个问题需要解决: 操作日志需要知道是哪个操作人修改的订单配送地址。 修改订单配送地址的操作日志需要绑定在配送的订单上,从而可以根据配送订单号查询出对这个配送订单的所有操作。 为了在注解上记录之前的配送地址是什么,在方法签名上添加了一个和业务无关的 oldAddress 的变量,这样就不优雅了。 为了解决前两个问题,我们需要把期望的操作日志使用形式改成下面的方式: @LogRecord( content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", operator = "#request.userName", bizNo="#request.deliveryOrderNo") public void modifyAddress(updateDeliveryRequest request, String oldAddress){ // 更新派送信息 电话,收件人、地址 doUpdate(request); } 修改后的代码在注解上添加两个参数,一个是操作人,一个是操作日志需要绑定的对象。但是,在普通的 Web 应用中用户信息都是保存在一个线程上下文的静态方法中,所以 operator 一般是这样的写法(假定获取当前登陆用户的方式是 UserContext.getCurrentUser())。 operator = "#{T(com.meituan.user.UserContext).getCurrentUser()}" 这样的话,每个 @LogRecord 的注解上的操作人都是这么长一串。为了避免过多的重复代码,我们可以把注解上的 operator 参数设置为非必填,这样用户可以填写操作人。但是,如果用户不填写我们就取 UserContext 的 user(下文会介绍如何取 user )。最后,最简单的日志变成了下面的形式: @LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", bizNo="#request.deliveryOrderNo") public void modifyAddress(updateDeliveryRequest request, String oldAddress){ // 更新派送信息 电话,收件人、地址 doUpdate(request); } 接下来,我们需要解决第三个问题:为了记录业务操作记录添加了一个 oldAddress 变量,不管怎么样这都不是一个好的实现方式,所以接下来,我们需要把 oldAddress 变量从修改地址的方法签名上去掉。但是操作日志确实需要 oldAddress 变量,怎么办呢? 要么和产品经理 PK 一下,让产品经理把文案从“修改了订单的配送地址:从 xx 修改到 yy” 改为 “修改了订单的配送地址为:yy”。但是从用户体验上来看,第一种文案更人性化一些,显然我们不会 PK 成功的。那么我们就必须要把这个 oldAddress 查询出来然后供操作日志使用了。还有一种解决办法是:把这个参数放到操作日志的线程上下文中,供注解上的模板使用。我们按照这个思路再改下操作日志的实现代码。 @LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", bizNo="#request.deliveryOrderNo") public void modifyAddress(updateDeliveryRequest request){ // 查询出原来的地址是什么 LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo())); // 更新派送信息 电话,收件人、地址 doUpdate(request); } 这时候可以看到,LogRecordContext 解决了操作日志模板上使用方法参数以外变量的问题,同时避免了为了记录操作日志修改方法签名的设计。虽然已经比之前的代码好了些,但是依然需要在业务代码里面加了一行业务逻辑无关的代码,如果有“强迫症”的同学还可以继续往下看,接下来我们会讲解自定义函数的解决方案。下面再看另一个例子: @LogRecord(content = "修改了订单的配送员:从“#oldDeliveryUserId”, 修改到“#request.userId”", bizNo="#request.deliveryOrderNo") public void modifyAddress(updateDeliveryRequest request){ // 查询出原来的地址是什么 LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo())); // 更新派送信息 电话,收件人、地址 doUpdate(request); } 这个操作日志的模板最后记录的内容是这样的格式:修改了订单的配送员:从 “10090”,修改到 “10099”,显然用户看到这样的操作日志是不明白的。用户对于用户 ID 是 10090 还是 10099 并不了解,用户期望看到的是:修改了订单的配送员:从“张三(18910008888)”,修改到“小明(13910006666)”。用户关心的是配送员的姓名和电话。但是我们方法中传递的参数只有配送员的 ID,没有配送员的姓名可电话。我们可以通过上面的方法,把用户的姓名和电话查询出来,然后通过 LogRecordContext 实现。 但是,“强迫症”是不期望操作日志的代码嵌入在业务逻辑中的。接下来,我们考虑另一种实现方式:自定义函数。如果我们可以通过自定义函数把用户 ID 转换为用户姓名和电话,那么就能解决这一问题,按照这个思路,我们把模板修改为下面的形式: @LogRecord(content = "修改了订单的配送员:从“{deliveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.userId}}”", bizNo="#request.deliveryOrderNo") public void modifyAddress(updateDeliveryRequest request){ // 查询出原来的地址是什么 LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo())); // 更新派送信息 电话,收件人、地址 doUpdate(request); } 其中 deliveryUser 是自定义函数,使用大括号把 Spring 的 SpEL 表达式包裹起来,这样做的好处:一是把 SpEL(Spring Expression Language,Spring表达式语言)和自定义函数区分开便于解析;二是如果模板中不需要 SpEL 表达式解析可以容易的识别出来,减少 SpEL 的解析提高性能。这时候我们发现上面代码还可以优化成下面的形式: @LogRecord(content = "修改了订单的配送员:从“{queryOldUser{#request.deliveryOrderNo()}}”, 修改到“{deveryUser{#request.userId}}”", bizNo="#request.deliveryOrderNo") public void modifyAddress(updateDeliveryRequest request){ // 更新派送信息 电话,收件人、地址 doUpdate(request); } 这样就不需要在 modifyAddress 方法中通过 LogRecordContext.putVariable() 设置老的快递员了,通过直接新加一个自定义函数 queryOldUser() 参数把派送订单传递进去,就能查到之前的配送人了,只需要让方法的解析在 modifyAddress() 方法执行之前运行。这样的话,我们让业务代码又变得纯净了起来,同时也让“强迫症”不再感到难受了。 4. 代码实现解析 4.1 代码结构 上面的操作日志主要是通过一个 AOP 拦截器实现的,整体主要分为 AOP 模块、日志解析模块、日志保存模块、Starter 模块;组件提供了4个扩展点,分别是:自定义函数、默认处理人、业务保存和查询;业务可以根据自己的业务特性定制符合自己业务的逻辑。 4.2 模块介绍 有了上面的分析,已经得出一种我们期望的操作日志记录的方式,那么接下来看看如何实现上面的逻辑。实现主要分为下面几个步骤: AOP 拦截逻辑 解析逻辑 模板解析 LogContext 逻辑 默认的 operator 逻辑 自定义函数逻辑 默认的日志持久化逻辑 Starter 封装逻辑 4.2.1 AOP 拦截逻辑 这块逻辑主要是一个拦截器,针对 @LogRecord 注解分析出需要记录的操作日志,然后把操作日志持久化,这里把注解命名为 @LogRecordAnnotation。接下来,我们看下注解的定义: @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface LogRecordAnnotation { String success(); String fail() default ""; String operator() default ""; String bizNo(); String category() default ""; String detail() default ""; String condition() default ""; } 注解中除了上面提到参数外,还增加了 fail、category、detail、condition 等参数,这几个参数是为了满足特定的场景,后面还会给出具体的例子。 参数名 描述 是否必填 success 操作日志的文本模板 是 fail 操作日志失败的文本版本 否 operator 操作日志的执行人 否 bizNo 操作日志绑定的业务对象标识 是 category 操作日志的种类 否 detail 扩展参数,记录操作日志的修改详情 否 condition 记录日志的条件 否 为了保持简单,组件的必填参数就两个。业务中的 AOP 逻辑大部分是使用 @Aspect 注解实现的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有问题的,组件为了兼容 Spring boot1.5 的版本我们手工实现 Spring 的 AOP 逻辑。 切面选择 AbstractBeanFactoryPointcutAdvisor 实现,切点是通过 StaticMethodMatcherPointcut 匹配包含 LogRecordAnnotation 注解的方法。通过实现 MethodInterceptor 接口实现操作日志的增强逻辑。 下面是拦截器的切点逻辑: public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable { // LogRecord的解析类 private LogRecordOperationSource logRecordOperationSource; @Override public boolean matches(@NonNull Method method, @NonNull Class<?> targetClass) { // 解析 这个 method 上有没有 @LogRecordAnnotation 注解,有的话会解析出来注解上的各个参数 return !CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method, targetClass)); } void setLogRecordOperationSource(LogRecordOperationSource logRecordOperationSource) { this.logRecordOperationSource = logRecordOperationSource; } } 切面的增强逻辑主要代码如下: @Override public Object invoke(MethodInvocation invocation) throws Throwable { Method method = invocation.getMethod(); // 记录日志 return execute(invocation, invocation.getThis(), method, invocation.getArguments()); } private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable { Class<?> targetClass = getTargetClass(target); Object ret = null; MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, ""); LogRecordContext.putEmptySpan(); Collection<LogRecordOps> operations = new ArrayList<>(); Map<String, String> functionNameAndReturnMap = new HashMap<>(); try { operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass); List<String> spElTemplates = getBeforeExecuteFunctionTemplate(operations); //业务逻辑执行前的自定义函数解析 functionNameAndReturnMap = processBeforeExecuteFunctionTemplate(spElTemplates, targetClass, method, args); } catch (Exception e) { log.error("log record parse before function exception", e); } try { ret = invoker.proceed(); } catch (Exception e) { methodExecuteResult = new MethodExecuteResult(false, e, e.getMessage()); } try { if (!CollectionUtils.isEmpty(operations)) { recordExecute(ret, method, args, operations, targetClass, methodExecuteResult.isSuccess(), methodExecuteResult.getErrorMsg(), functionNameAndReturnMap); } } catch (Exception t) { //记录日志错误不要影响业务 log.error("log record parse exception", t); } finally { LogRecordContext.clear(); } if (methodExecuteResult.throwable != null) { throw methodExecuteResult.throwable; } return ret; } 拦截逻辑的流程: 可以看到,操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常之后会先捕获异常,等操作日志持久化完成后再抛出异常。在业务的方法执行之前,会对提前解析的自定义函数求值,解决了前面提到的需要查询修改之前的内容。 4.2.2 解析逻辑 模板解析 Spring 3 提供了一个非常强大的功能:Spring EL,SpEL 在 Spring 产品中是作为表达式求值的核心基础模块,它本身是可以脱离 Spring 独立使用的。举个例子: public static void main(String[] args) { SpelExpressionParser parser = new SpelExpressionParser(); Expression expression = parser.parseExpression("#root.purchaseName"); Order order = new Order(); order.setPurchaseName("张三"); System.out.println(expression.getValue(order)); } 这个方法将打印 “张三”。LogRecord 解析的类图如下: 解析核心类:LogRecordValueParser 里面封装了自定义函数和 SpEL 解析类 LogRecordExpressionEvaluator。 public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator { private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64); private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64); public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) { return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class); } } LogRecordExpressionEvaluator 继承自 CachedExpressionEvaluator 类,这个类里面有两个 Map,一个是 expressionCache 一个是 targetMethodCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后根据传入的 Object 获取到对应的值,所以 expressionCache 是为了缓存方法、表达式和 SpEL 的 Expression 的对应关系,让方法注解上添加的 SpEL 表达式只解析一次。 下面的 targetMethodCache 是为了缓存传入到 Expression 表达式的 Object。核心的解析逻辑是上面最后一行代码。 getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class); getExpression 方法会从 expressionCache 中获取到 @LogRecordAnnotation 注解上的表达式的解析 Expression 的实例,然后调用 getValue 方法,getValue 传入一个 evalContext 就是类似上面例子中的 order 对象。其中 Context 的实现将会在下文介绍。 日志上下文实现 下面的例子把变量放到了 LogRecordContext 中,然后 SpEL 表达式就可以顺利的解析方法上不存在的参数了,通过上面的 SpEL 的例子可以看出,要把方法的参数和 LogRecordContext 中的变量都放到 SpEL 的 getValue 方法的 Object 中才可以顺利的解析表达式的值。下面看下如何实现: @LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”", bizNo="#request.getDeliveryOrderNo()") public void modifyAddress(updateDeliveryRequest request){ // 查询出原来的地址是什么 LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo())); // 更新派送信息 电话,收件人、地址 doUpdate(request); } 在 LogRecordValueParser 中创建了一个 EvaluationContext,用来给 SpEL 解析方法参数和 Context 中的变量。相关代码如下: EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory); 在解析的时候调用 getValue 方法传入的参数 evalContext,就是上面这个 EvaluationContext 对象。下面是 LogRecordEvaluationContext 对象的继承体系: LogRecordEvaluationContext 做了三个事情: 把方法的参数都放到 SpEL 解析的 RootObject 中。 把 LogRecordContext 中的变量都放到 RootObject 中。 把方法的返回值和 ErrorMsg 都放到 RootObject 中。 LogRecordEvaluationContext 的代码如下: public class LogRecordEvaluationContext extends MethodBasedEvaluationContext { public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments, ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) { //把方法的参数都放到 SpEL 解析的 RootObject 中 super(rootObject, method, arguments, parameterNameDiscoverer); //把 LogRecordContext 中的变量都放到 RootObject 中 Map<String, Object> variables = LogRecordContext.getVariables(); if (variables != null && variables.size() > 0) { for (Map.Entry<String, Object> entry : variables.entrySet()) { setVariable(entry.getKey(), entry.getValue()); } } //把方法的返回值和 ErrorMsg 都放到 RootObject 中 setVariable("_ret", ret); setVariable("_errorMsg", errorMsg); } } 下面是 LogRecordContext 的实现,这个类里面通过一个 ThreadLocal 变量保持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。 public class LogRecordContext { private static final InheritableThreadLocal<Stack<Map<String, Object>>> variableMapStack = new InheritableThreadLocal<>(); //其他省略.... } 上面使用了 InheritableThreadLocal,所以在线程池的场景下使用 LogRecordContext 会出现问题,如果支持线程池可以使用阿里巴巴开源的 TTL 框架。那这里为什么不直接设置一个 ThreadLocal<Map<String, Object>> 对象,而是要设置一个 Stack 结构呢?我们看一下这么做的原因是什么。 @LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”", bizNo="#request.getDeliveryOrderNo()") public void modifyAddress(updateDeliveryRequest request){ // 查询出原来的地址是什么 LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo())); // 更新派送信息 电话,收件人、地址 doUpdate(request); } 上面代码的执行流程如下: 看起来没有什么问题,但是使用 LogRecordAnnotation 的方法里面嵌套了另一个使用 LogRecordAnnotation 方法的时候,流程就变成下面的形式: 可以看到,当方法二执行了释放变量后,继续执行方法一的 logRecord 逻辑,此时解析的时候 ThreadLocal<Map<String, Object>>的 Map 已经被释放掉,所以方法一就获取不到对应的变量了。方法一和方法二共用一个变量 Map 还有个问题是:如果方法二设置了和方法一相同的变量两个方法的变量就会被相互覆盖。所以最终 LogRecordContext 的变量的生命周期需要是下面的形式: LogRecordContext 每执行一个方法都会压栈一个 Map,方法执行完之后会 Pop 掉这个 Map,从而避免变量共享和覆盖问题。 默认操作人逻辑 在 LogRecordInterceptor 中 IOperatorGetService 接口,这个接口可以获取到当前的用户。下面是接口的定义: public interface IOperatorGetService { /** * 可以在里面外部的获取当前登陆的用户,比如 UserContext.getCurrentUser() * * @return 转换成Operator返回 */ Operator getUser(); } 下面给出了从用户上下文中获取用户的例子: public class DefaultOperatorGetServiceImpl implements IOperatorGetService { @Override public Operator getUser() { //UserUtils 是获取用户上下文的方法 return Optional.ofNullable(UserUtils.getUser()) .map(a -> new Operator(a.getName(), a.getLogin())) .orElseThrow(()->new IllegalArgumentException("user is null")); } } 组件在解析 operator 的时候,就判断注解上的 operator 是否是空,如果注解上没有指定,我们就从 IOperatorGetService 的 getUser 方法获取了。如果都获取不到,就会报错。 String realOperatorId = ""; if (StringUtils.isEmpty(operatorId)) { if (operatorGetService.getUser() == null || StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) { throw new IllegalArgumentException("user is null"); } realOperatorId = operatorGetService.getUser().getOperatorId(); } else { spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail); } 自定义函数逻辑 自定义函数的类图如下: 下面是 IParseFunction 的接口定义:executeBefore 函数代表了自定义函数是否在业务代码执行之前解析,上面提到的查询修改之前的内容。 public interface IParseFunction { default boolean executeBefore(){ return false; } String functionName(); String apply(String value); } ParseFunctionFactory 的代码比较简单,它的功能是把所有的 IParseFunction 注入到函数工厂中。 public class ParseFunctionFactory { private Map<String, IParseFunction> allFunctionMap; public ParseFunctionFactory(List<IParseFunction> parseFunctions) { if (CollectionUtils.isEmpty(parseFunctions)) { return; } allFunctionMap = new HashMap<>(); for (IParseFunction parseFunction : parseFunctions) { if (StringUtils.isEmpty(parseFunction.functionName())) { continue; } allFunctionMap.put(parseFunction.functionName(), parseFunction); } } public IParseFunction getFunction(String functionName) { return allFunctionMap.get(functionName); } public boolean isBeforeFunction(String functionName) { return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore(); } } DefaultFunctionServiceImpl 的逻辑就是根据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的 apply 方法上最后返回函数的值。 public class DefaultFunctionServiceImpl implements IFunctionService { private final ParseFunctionFactory parseFunctionFactory; public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) { this.parseFunctionFactory = parseFunctionFactory; } @Override public String apply(String functionName, String value) { IParseFunction function = parseFunctionFactory.getFunction(functionName); if (function == null) { return value; } return function.apply(value); } @Override public boolean beforeFunction(String functionName) { return parseFunctionFactory.isBeforeFunction(functionName); } } 4.2.3 日志持久化逻辑 同样在 LogRecordInterceptor 的代码中引用了 ILogRecordService,这个 Service 主要包含了日志记录的接口。 public interface ILogRecordService { /** * 保存 log * * @param logRecord 日志实体 */ void record(LogRecord logRecord); } 业务可以实现这个保存接口,然后把日志保存在任何存储介质上。这里给了一个 2.2 节介绍的通过 log.info 保存在日志文件中的例子,业务可以把保存设置成异步或者同步,可以和业务放在一个事务中保证操作日志和业务的一致性,也可以新开辟一个事务,保证日志的错误不影响业务的事务。业务可以保存在 Elasticsearch、数据库或者文件中,用户可以根据日志结构和日志的存储实现相应的查询逻辑。 @Slf4j public class DefaultLogRecordServiceImpl implements ILogRecordService { @Override // @Transactional(propagation = Propagation.REQUIRES_NEW) public void record(LogRecord logRecord) { log.info("【logRecord】log={}", logRecord); } } 4.2.4 Starter 逻辑封装 上面逻辑代码已经介绍完毕,那么接下来需要把这些组件组装起来,然后让用户去使用。在使用这个组件的时候只需要在 Springboot 的入口上添加一个注解 @EnableLogRecord(tenant = "com.mzt.test")。其中 tenant 代表租户,是为了多租户使用的。 @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) @EnableTransactionManagement @EnableLogRecord(tenant = "com.mzt.test") public class Main { public static void main(String[] args) { SpringApplication.run(Main.class, args); } } 再看下 EnableLogRecord 的代码,代码中 Import 了 LogRecordConfigureSelector.class,在 LogRecordConfigureSelector 类中暴露了 LogRecordProxyAutoConfiguration 类。 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Import(LogRecordConfigureSelector.class) public @interface EnableLogRecord { String tenant(); AdviceMode mode() default AdviceMode.PROXY; } LogRecordProxyAutoConfiguration 就是装配上面组件的核心类了,代码如下: @Configuration @Slf4j public class LogRecordProxyAutoConfiguration implements ImportAware { private AnnotationAttributes enableLogRecord; @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public LogRecordOperationSource logRecordOperationSource() { return new LogRecordOperationSource(); } @Bean @ConditionalOnMissingBean(IFunctionService.class) public IFunctionService functionService(ParseFunctionFactory parseFunctionFactory) { return new DefaultFunctionServiceImpl(parseFunctionFactory); } @Bean public ParseFunctionFactory parseFunctionFactory(@Autowired List<IParseFunction> parseFunctions) { return new ParseFunctionFactory(parseFunctions); } @Bean @ConditionalOnMissingBean(IParseFunction.class) public DefaultParseFunction parseFunction() { return new DefaultParseFunction(); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public BeanFactoryLogRecordAdvisor logRecordAdvisor(IFunctionService functionService) { BeanFactoryLogRecordAdvisor advisor = new BeanFactoryLogRecordAdvisor(); advisor.setLogRecordOperationSource(logRecordOperationSource()); advisor.setAdvice(logRecordInterceptor(functionService)); return advisor; } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) public LogRecordInterceptor logRecordInterceptor(IFunctionService functionService) { LogRecordInterceptor interceptor = new LogRecordInterceptor(); interceptor.setLogRecordOperationSource(logRecordOperationSource()); interceptor.setTenant(enableLogRecord.getString("tenant")); interceptor.setFunctionService(functionService); return interceptor; } @Bean @ConditionalOnMissingBean(IOperatorGetService.class) @Role(BeanDefinition.ROLE_APPLICATION) public IOperatorGetService operatorGetService() { return new DefaultOperatorGetServiceImpl(); } @Bean @ConditionalOnMissingBean(ILogRecordService.class) @Role(BeanDefinition.ROLE_APPLICATION) public ILogRecordService recordService() { return new DefaultLogRecordServiceImpl(); } @Override public void setImportMetadata(AnnotationMetadata importMetadata) { this.enableLogRecord = AnnotationAttributes.fromMap( importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(), false)); if (this.enableLogRecord == null) { log.info("@EnableCaching is not present on importing class"); } } } 这个类继承 ImportAware 是为了拿到 EnableLogRecord 上的租户属性,这个类使用变量 logRecordAdvisor 和 logRecordInterceptor 装配了 AOP,同时把自定义函数注入到了 logRecordAdvisor 中。 对外扩展类:分别是IOperatorGetService、ILogRecordService、IParseFunction。业务可以自己实现相应的接口,因为配置了 @ConditionalOnMissingBean,所以用户的实现类会覆盖组件内的默认实现。 5. 总结 这篇文章介绍了操作日志的常见写法,以及如何让操作日志的实现更加简单、易懂;通过组件的四个模块,介绍了组件的具体实现。对于上面的组件介绍,大家如果有疑问,也欢迎在文末留言,我们会进行答疑。 6. 作者简介 站通,2020年加入美团,基础研发平台/研发质量及效率部工程师。 7. 参考资料 Canal spring-framework Spring Expression Language (SpEL) ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal三者之间区别 8. 招聘信息 美团研发质量及效率部 ,致力于建设业界一流的持续交付平台,现招聘基础组件方向相关的工程师,坐标北京/上海。欢迎感兴趣的同学加入。可投递简历至:chao.yu@meituan.com(邮件主题请注明:美团研发质量及效率部)。 阅读美团技术团队更多技术文章合集 前端 | 算法 | 后端 | 数据 | 安全 | 运维 | iOS | Android | 测试 | 在公众号菜单栏对话框回复【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。 | 本文系美团技术团队出品,著作权归属美团。欢迎出于分享和交流等非商业目的转载或使用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者使用。任何商用行为,请发送邮件至tech@meituan.com申请授权。

优秀的个人博客,低调大师

每日一博 | 源码解读 Dubbo 分层设计思想

一、Dubbo分层整体设计概述 我们先从下图开始简单介绍Dubbo分层设计概念: (引用自Duboo开发指南-框架设计文档) 如图描述Dubbo实现的RPC整体分10层:service、config、proxy、registry、cluster、monitor、protocol、exchange、transport、serialize。 service:使用方定义的接口和实现类; config:负责解析Dubbo定义的配置,比如注解和xml配置,各种参数; proxy:主要负责生成消费者和提供者的代理对象,加载框架功能,比如提供者过滤器链,扩展点; registry:负责注册服务的定义和实现类的装载; cluster:只有消费者有这么一层,负责包装多个服务提供者成一个‘大提供者’,加载负载均衡、路有等扩展点; monitor:定义监控服务,加载监控实现提供者; protocol:封装RPC调用接口,管理调用实体的生命周期; exchange:封装请求响应模式,同步转异步; transport:抽象传输层模型,兼容netty、mina、grizzly等通讯框架; serialize:抽象序列化模型,兼容多种序列化框架,包括:fastjson、fst、hessian2、kryo、kryo2、protobuf等,通过序列化支持跨语言的方式,支持跨语言的rpc调用; Dubbo这么分层的目的在于实现层与层之间的解耦,每一层都定义了接口规范,也可以根据不同的业务需求定制、加载不同的实现,具有极高的扩展性。 1.1. RPC调用过程 接下来结合上图简单描述一次完整的rpc调用过程: 从Dubbo分层的角度看,详细时序图如下,蓝色部分是服务消费端,浅绿色部分是服务提供端,时序图从消费端一次Dubbo方法调用开始,到服务端本地方法执行结束。 从Dubbo核心领域对象的角度看,我们引用Dubbo官方文档说明,如下图所示。Dubbo核心领域对象是Invoker,消费端代理对象是proxy,包装了Invoker的调用;服务端代理对象是一个Invoker,他通过exporter包装,当服务端接收到调用请求后,通过exporter找到Invoker,Invoker去实际执行用户的业务逻辑。 (引用自Dubbo官方文档) 1.2 Dubbo服务的注册和发现流程 下图出自开发指南-框架设计-引用服务时序,主要流程是:从注册中心订阅服务提供者,然后启动tcp服务连接远端提供者,将多个服务提供者合并成一个Invoker,用这个Invoker创建代理对象。 下图出自开发指南-框架设计-暴露服务时序,主要流程是:创建本地服务的代理Invoker,启动tcp服务暴露服务,然后将服务注册到注册中心。 接下来我们结合Dubbo服务的注册和发现,从配置层开始解释每一层的作用和原理。 示例服务接口定义如下: public interface CouponServiceViewFacade { /** * 查询单张优惠券 */ CouponViewDTO query(String code); } 二、配置层 2.1. 做什么 配置层提供配置处理工具类,在容器启动的时候,通过ServiceConfig.export实例化服务提供者,ReferenceConfig.get实例化服务消费者对象。 Dubbo应用使用spring容器启动时,Dubbo服务提供者配置处理器通过ServiceConfig.export启动Dubbo远程服务暴露本地服务。Dubbo服务消费者配置处理器通过ReferenceConfig.get实例化一个代理对象,并通过注册中心服务发现,连接远端服务提供者。 Dubbo配置可以使用注解和xml两种形式,本文采用注解的形式进行说明。 2.2. 怎么做 2.2.1 服务消费端的解析 Spring容器启动过程中,填充bean属性时,对含有Dubbo引用注解的属性使用org.apache.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor进行初始化。如下是ReferenceAnnotationBeanPostProcessor的构造方法,Dubbo服务消费者注解处理器处理以下三个注解:DubboReference.class、Reference.class、com.alibaba.dubbo.config.annotation.Reference.class修饰的类。 ReferenceAnnotationBeanPostProcessor类定义: public class ReferenceAnnotationBeanPostProcessor extends AbstractAnnotationBeanPostProcessor implements ApplicationContextAware { public ReferenceAnnotationBeanPostProcessor() { super(DubboReference.class, Reference.class, com.alibaba.dubbo.config.annotation.Reference.class); } } Dubbo服务发现到这一层,Dubbo即将开始构建服务消费者的代理对象,CouponServiceViewFacade接口的代理实现类。 2.2.2 服务提供端的解析 Spring容器启动的时候,加载注解@org.apache.dubbo.config.spring.context.annotation.DubboComponentScan指定范围的类,并初始化;初始化使用dubbo实现的扩展点org.apache.dubbo.config.spring.beans.factory.annotation.ServiceClassPostProcessor。 ServiceClassPostProcessor处理的注解类有DubboService.class,Service.class,com.alibaba.dubbo.config.annotation.Service.class。 如下是ServiceClassPostProcessor类定义: public class ServiceClassPostProcessor implements BeanDefinitionRegistryPostProcessor, EnvironmentAware, ResourceLoaderAware, BeanClassLoaderAware { private final static List<Class<? extends Annotation>> serviceAnnotationTypes = asList( DubboService.class,Service.class,com.alibaba.dubbo.config.annotation.Service.class ); 。。。 } 等待Spring容器ContextRefreshedEvent事件,启动Dubbo应用服务监听端口,暴露本地服务。 Dubbo服务注册到这一层,Dubbo即将开始构建服务提供者的代理对象,CouponServiceViewFacade实现类的反射代理类。 三、 代理层 3.1 做什么 为服务消费者生成代理实现实例,为服务提供者生成反射代理实例。 CouponServiceViewFacade的代理实现实例,消费端在调用query方法的时候,实际上是调用代理实现实例的query方法,通过他调用远程服务。 // // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.apache.dubbo.common.bytecode; public class proxy1 implements DC, Destroyable, CouponServiceViewFacade, EchoService { public static Method[] methods; private InvocationHandler handler; public proxy1(InvocationHandler var1) { this.handler = var1; } public proxy1() { } public CouponViewDTO query(String var1) { Object[] var2 = new Object[]{var1}; Object var3 = this.handler.invoke(this, methods[0], var2); return (CouponViewDTO)var3; } } CouponServiceViewFacade的反射代理实例,服务端接收到请求后,通过该实例的Invoke方法最终执行本地方法query。 /** * InvokerWrapper */ public class AbstractProxyInvoker<CouponServiceViewFacade> implements Invoker<CouponServiceViewFacade> { // 。。。 public AbstractProxyInvoker(CouponServiceViewFacade proxy, Class<CouponServiceViewFacade> type, URL url) { //。。。 this.proxy = proxy; this.type = type; this.url = url; } @Override public Result invoke(Invocation invocation) throws RpcException { //。。。 Object value = doInvoke(proxy, invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments()); //。。。 } protected Object doInvoke(CouponServiceViewFacade proxy, String methodName, Class<?>[] parameterTypes, Object[] arguments) throws Throwable{ //。。。 return wrapper.invokeMethod(proxy, methodName, parameterTypes, arguments); } } 3.2 怎么做 Dubbo代理工厂接口定义如下,定义了服务提供者和服务消费者的代理对象工厂方法。服务提供者代理对象和服务消费者代理对象都是通过工厂方法创建,工厂实现类可以通过SPI自定义扩展。 @SPI("javassist") public interface ProxyFactory { // 生成服务消费者代理对象 @Adaptive({PROXY_KEY}) <T> T getProxy(Invoker<T> invoker) throws RpcException; // 生成服务消费者代理对象 @Adaptive({PROXY_KEY}) <T> T getProxy(Invoker<T> invoker, boolean generic) throws RpcException; // 生成服务提供者代理对象 @Adaptive({PROXY_KEY}) <T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) throws RpcException; } 3.2.1 服务消费者 3.2.1.1 创建服务消费者代理类 默认采用Javaassist代理工厂实现,Proxy.getProxy(interfaces)创建代理工厂类,newInstance创建具体代理对象。 public class JavassistProxyFactory extends AbstractProxyFactory { @Override @SuppressWarnings("unchecked") public <T> T getProxy(Invoker<T> invoker, Class<?>[] interfaces) { return (T) Proxy.getProxy(interfaces).newInstance(new InvokerInvocationHandler(invoker)); } 。。。 } 3.2.1.2 服务消费者代理 Dubbo为每个服务消费者生成两个代理类:代理工厂类,接口代理类。 CouponServiceViewFacade代理工厂类: public class Proxy1 extends Proxy implements DC { public Proxy1() { } public Object newInstance(InvocationHandler var1) { return new proxy1(var1); } } 最终生成的CouponServiceViewFacade的代理对象如下,其中handler的实现类是InvokerInvocationHandler,this.handler.invoke方法发起Dubbo调用。 // // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.apache.dubbo.common.bytecode; public class proxy1 implements DC, Destroyable, CouponServiceViewFacade, EchoService { public static Method[] methods; private InvocationHandler handler; public proxy1(InvocationHandler var1) { this.handler = var1; } public proxy1() { } public CouponViewDTO query(String var1) { Object[] var2 = new Object[]{var1}; Object var3 = this.handler.invoke(this, methods[0], var2); return (CouponViewDTO)var3; } } 3.2.2 服务提供者 3.2.2.1 创建服务提供者代理类 默认Javaassist代理工厂实现,使用Wrapper包装本地服务提供者。proxy是实际的服务提供者实例,即CouponServiceViewFacade的本地实现类,type是接口类定义,URL是injvm协议URL。 public class JavassistProxyFactory extends AbstractProxyFactory { 。。。 @Override public <T> Invoker<T> getInvoker(T proxy, Class<T> type, URL url) { // 代理包装类,包装了本地的服务提供者 final Wrapper wrapper = Wrapper.getWrapper(proxy.getClass().getName().indexOf('$') < 0 ? proxy.getClass() : type); // 代理类入口 return new AbstractProxyInvoker<T>(proxy, type, url) { @Override protected Object doInvoke(T proxy, String methodName, Class<?>[] parameterTypes, Object[] arguments) throws Throwable { return wrapper.invokeMethod(proxy, methodName, parameterTypes, arguments); } }; } } 3.2.2.2 Wrapper包装类 Dubbo为每个服务提供者的本地实现生成一个Wrapper代理类,抽象Wrapper类定义如下: public abstract class Wrapper { 。。。 abstract public Object invokeMethod(Object instance, String mn, Class<?>[] types, Object[] args) throws NoSuchMethodException, InvocationTargetException; } 具体Wrapper代理类使用字节码技术动态生成,本地服务CouponServiceViewFacade的代理包装类举例: // // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.apache.dubbo.common.bytecode; import com.xxx.CouponServiceViewFacade; import java.lang.reflect.InvocationTargetException; import java.util.Map; import org.apache.dubbo.common.bytecode.ClassGenerator.DC; public class Wrapper25 extends Wrapper implements DC { 。。。 public Wrapper25() { } public Object invokeMethod(Object var1, String var2, Class[] var3, Object[] var4) throws InvocationTargetException { CouponServiceViewFacade var5; try { var5 = (CouponServiceViewFacade)var1; } catch (Throwable var8) { throw new IllegalArgumentException(var8); } try { if ("query".equals(var2) && var3.length == 1) { return var5.query((String)var4[0]); } } catch (Throwable var9) { throw new InvocationTargetException(var9); } throw new NoSuchMethodException("Not found method \"" + var2 + "\" in class com.xxx.CouponServiceViewFacade."); } 。。。 } 在服务初始化流程中,服务消费者代理对象生成后初始化就完成了,服务消费端的初始化顺序:ReferenceConfig.get->从注册中心订阅服务->启动客户端->创建DubboInvoker->构建ClusterInvoker→创建服务代理对象; 而服务提供端的初始化才刚开始,服务提供端的初始化顺序:ServiceConfig.export->创建AbstractProxyInvoker,通过Injvm协议关联本地服务->启动服务端→注册服务到注册中心。 接下来我们讲注册层。 四、注册层 4.1 做什么 封装服务地址的注册与发现,以服务 URL 为配置中心。服务提供者本地服务启动成功后,监听Dubbo端口成功后,通过注册协议发布到注册中心;服务消费者通过注册协议订阅服务,启动本地应用连接远程服务。 注册协议URL举例: zookeeper://xxx/org.apache.dubbo.registry.RegistryService?application=xxx&... 4.2 怎么做 注册服务工厂接口定义如下,注册服务实现通过SPI扩展,默认是zk作为注册中心。 @SPI("dubbo") public interface RegistryFactory { @Adaptive({"protocol"}) Registry getRegistry(URL url); } 注册服务接口定义; public interface RegistryService { void register(URL url); void unregister(URL url); void subscribe(URL url, NotifyListener listener); void unsubscribe(URL url, NotifyListener listener); List<URL> lookup(URL url); } 五、集群层 5.1 做什么 服务消费方从注册中心订阅服务提供者后,将多个提供者包装成一个提供者,并且封装路由及负载均衡策略;并桥接注册中心,以 Invoker 为中心,扩展接口为 Cluster, Directory, Router, LoadBalance; 服务提供端不存在集群层。 5.2 怎么做 5.2.1 Cluster 集群领域主要负责将多个服务提供者包装成一个ClusterInvoker,注入路由处理器链和负载均衡策略。主要策略有:failover、failfast、failsafe、failback、forking、available、mergeable、broadcast、zone-aware。 集群接口定义如下,只有一个方法:从服务目录中的多个服务提供者构建一个ClusterInvoker。 作用是对上层-代理层屏蔽集群层的逻辑;代理层调用服务方法只需执行Invoker.invoke,然后通过ClusterInvoker内部的路由策略和负载均衡策略计算具体执行哪个远端服务提供者。 @SPI(Cluster.DEFAULT) public interface Cluster { String DEFAULT = FailoverCluster.NAME; @Adaptive <T> Invoker<T> join(Directory<T> directory) throws RpcException; 。。。 } ClusterInvoker执行逻辑,先路由策略过滤,然后负载均衡策略选择最终的远端服务提供者。示例代理如下: public abstract class AbstractClusterInvoker<T> implements ClusterInvoker<T> { 。。。 @Override public Result invoke(final Invocation invocation) throws RpcException { checkWhetherDestroyed(); // binding attachments into invocation. Map<String, Object> contextAttachments = RpcContext.getContext().getObjectAttachments(); if (contextAttachments != null && contextAttachments.size() != 0) { ((RpcInvocation) invocation).addObjectAttachments(contextAttachments); } // 集群invoker执行时,先使用路由链过滤服务提供者 List<Invoker<T>> invokers = list(invocation); LoadBalance loadbalance = initLoadBalance(invokers, invocation); RpcUtils.attachInvocationIdIfAsync(getUrl(), invocation); return doInvoke(invocation, invokers, loadbalance); } 。。。 } 5.2.2 Directory 服务目录接口定义如下,Dubbo方法接口调用时,将方法信息包装成invocation,通过Directory.list过滤可执行的远端服务。 通过org.apache.dubbo.registry.integration.RegistryDirectory桥接注册中心,监听注册中心的路由配置修改、服务治理等事件。 public interface Directory<T> extends Node { Class<T> getInterface(); List<Invoker<T>> list(Invocation invocation) throws RpcException; List<Invoker<T>> getAllInvokers(); URL getConsumerUrl(); } 5.2.3 Router 从已知的所有服务提供者中根据路由规则刷选服务提供者。 服务订阅的时候初始化路由处理器链,调用远程服务的时候先使用路由链过滤服务提供者,再通过负载均衡选择具体的服务节点。 路由处理器链工具类,提供路由筛选服务,监听更新服务提供者。 public class RouterChain<T> { 。。。 public List<Invoker<T>> route(URL url, Invocation invocation) { List<Invoker<T>> finalInvokers = invokers; for (Router router : routers) { finalInvokers = router.route(finalInvokers, url, invocation); } return finalInvokers; } /** * Notify router chain of the initial addresses from registry at the first time. * Notify whenever addresses in registry change. */ public void setInvokers(List<Invoker<T>> invokers) { //路由链监听更新服务提供者 this.invokers = (invokers == null ? Collections.emptyList() : invokers); routers.forEach(router -> router.notify(this.invokers)); } } 订阅服务的时候,将路由链注入到RegistryDirectory中; public class RegistryProtocol implements Protocol { 。。。 private <T> Invoker<T> doRefer(Cluster cluster, Registry registry, Class<T> type, URL url) { 。。。 // 服务目录初始化路由链 directory.buildRouterChain(subscribeUrl); directory.subscribe(toSubscribeUrl(subscribeUrl)); 。。。 return registryInvokerWrapper; } 。。。 } 5.2.4 LoadBalance 根据不同的负载均衡策略从可使用的远端服务实例中选择一个,负责均衡接口定义如下: @SPI(RandomLoadBalance.NAME) public interface LoadBalance { @Adaptive("loadbalance") <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException; } 六、监控层 6.1 做什么 监控RPC调用次数和调用时间,以Statistics为中心,扩展接口为 MonitorFactory, Monitor, MonitorService。 6.2 怎么做 监控工厂接口定义,通过SPI方式进行扩展; @SPI("dubbo") public interface MonitorFactory { @Adaptive("protocol") Monitor getMonitor(URL url); } @Adaptive("protocol") Monitor getMonitor(URL url); 监控服务接口定义如下,定义了一些默认的监控维度和指标项; public interface MonitorService { // 监控维度 String APPLICATION = "application"; String INTERFACE = "interface"; String METHOD = "method"; String GROUP = "group"; String VERSION = "version"; String CONSUMER = "consumer"; String PROVIDER = "provider"; String TIMESTAMP = "timestamp"; //监控指标项 String SUCCESS = "success"; String FAILURE = "failure"; String INPUT = INPUT_KEY; String OUTPUT = OUTPUT_KEY; String ELAPSED = "elapsed"; String CONCURRENT = "concurrent"; String MAX_INPUT = "max.input"; String MAX_OUTPUT = "max.output"; String MAX_ELAPSED = "max.elapsed"; String MAX_CONCURRENT = "max.concurrent"; void collect(URL statistics); List<URL> lookup(URL query); } 6.2.1 MonitorFilter 通过过滤器的方式收集服务的调用次数和调用时间,默认实现: org.apache.dubbo.monitor.dubbo.DubboMonitor。 七、协议层 7.1 做什么 封装 RPC 调用,以 Invocation, Result 为中心,扩展接口为 Protocol, Invoker, Exporter。 接下来介绍Dubbo RPC过程中的常用概念: 1)Invocation是请求会话领域模型,每次请求有相应的Invocation实例,负责包装dubbo方法信息为请求参数; 2)Result是请求结果领域模型,每次请求都有相应的Result实例,负责包装dubbo方法响应; 3)Invoker是实体域,代表一个可执行实体,有本地、远程、集群三类; 4)Exporter服务提供者Invoker管理实体; 5)Protocol是服务域,管理Invoker的生命周期,提供服务的暴露和引用入口; 服务初始化流程中,从这一层开始进行远程服务的暴露和连接引用。 对于CouponServiceViewFacade服务来说,服务提供端会监听Dubbo端口启动tcp服务;服务消费端通过注册中心发现服务提供者信息,启动tcp服务连接远端提供者。 7.2 怎么做 协议接口定义如下,统一抽象了不同协议的服务暴露和引用模型,比如InjvmProtocol只需将Exporter,Invoker关联本地实现。DubboProtocol暴露服务的时候,需要监控本地端口启动服务;引用服务的时候,需要连接远端服务。 @SPI("dubbo") public interface Protocol { int getDefaultPort(); @Adaptive <T> Exporter<T> export(Invoker<T> invoker) throws RpcException; @Adaptive <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException; void destroy(); default List<ProtocolServer> getServers() { return Collections.emptyList(); } } Invoker接口定义 Invocation是RPC调用的会话对象,负责包装请求参数;Result是RPC调用的结果对象,负责包装RPC调用的结果对象,包括异常类信息; public interface Invoker<T> extends Node { Class<T> getInterface(); Result invoke(Invocation invocation) throws RpcException; } 7.2.1 服务的暴露和引用 服务暴露的时候,开启RPC服务端;引用服务的时候,开启RPC客户端。 public class DubboProtocol extends AbstractProtocol { 。。。 @Override public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException { 。。。 // 开启rpc服务端 openServer(url); optimizeSerialization(url); return exporter; } @Override public <T> Invoker<T> protocolBindingRefer(Class<T> serviceType, URL url) throws RpcException { optimizeSerialization(url); // 创建dubbo invoker,开启rpc客户端 DubboInvoker<T> invoker = new DubboInvoker<T>(serviceType, url, getClients(url), invokers); invokers.add(invoker); return invoker; } 。。。 } 7.2.2 服务端响应请求 接收响应请求; private ExchangeHandler requestHandler = new ExchangeHandlerAdapter() { @Override public CompletableFuture<Object> reply(ExchangeChannel channel, Object message) throws RemotingException { 。。。 Invocation inv = (Invocation) message; Invoker<?> invoker = getInvoker(channel, inv); RpcContext.getContext().setRemoteAddress(channel.getRemoteAddress()); //调用本地服务 Result result = invoker.invoke(inv); return result.thenApply(Function.identity()); } 。。。 }; 7.2.3 客户端发送请求 调用远程服务; public class DubboInvoker<T> extends AbstractInvoker<T> { 。。。 @Override protected Result doInvoke(final Invocation invocation) throws Throwable { 。。。 boolean isOneway = RpcUtils.isOneway(getUrl(), invocation); int timeout = calculateTimeout(invocation, methodName); if (isOneway) { boolean isSent = getUrl().getMethodParameter(methodName, Constants.SENT_KEY, false); currentClient.send(inv, isSent); return AsyncRpcResult.newDefaultAsyncResult(invocation); } else { ExecutorService executor = getCallbackExecutor(getUrl(), inv); CompletableFuture<AppResponse> appResponseFuture = currentClient.request(inv, timeout, executor).thenApply(obj -> (AppResponse) obj); FutureContext.getContext().setCompatibleFuture(appResponseFuture); AsyncRpcResult result = new AsyncRpcResult(appResponseFuture, inv); result.setExecutor(executor); return result; } } } 八、交换层 8.1 做什么 封装请求响应模式,同步转异步,以 Request, Response 为中心,扩展接口为 Exchanger, ExchangeChannel, ExchangeClient, ExchangeServer。 使用request包装Invocation作为完整的请求对象,使用response包装result作为完整的响应对象;Request、Response相比Invocation、Result添加了Dubbo的协议头。 8.2 怎么做 交换器对象接口定义,定义了远程服务的绑定和连接,使用SPI方式进行扩展; @SPI(HeaderExchanger.NAME) public interface Exchanger { @Adaptive({Constants.EXCHANGER_KEY}) ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException; @Adaptive({Constants.EXCHANGER_KEY}) ExchangeClient connect(URL url, ExchangeHandler handler) throws RemotingException; } @Adaptive({Constants.EXCHANGER_KEY}) ExchangeServer bind(URL url, ExchangeHandler handler) throws RemotingException; @Adaptive({Constants.EXCHANGER_KEY}) ExchangeClient connect(URL url, ExchangeHandler handler) throws RemotingException; 交换层模型类图: 8.2.1 服务提供者 服务提供端接收到请求后,本地执行,发送响应结果; public class HeaderExchangeHandler implements ChannelHandlerDelegate { 。。。 void handleRequest(final ExchangeChannel channel, Request req) throws RemotingException { //封装响应 Response res = new Response(req.getId(), req.getVersion()); 。。。 Object msg = req.getData(); try { CompletionStage<Object> future = handler.reply(channel, msg); future.whenComplete((appResult, t) -> { try { if (t == null) { res.setStatus(Response.OK); res.setResult(appResult); } else { res.setStatus(Response.SERVICE_ERROR); res.setErrorMessage(StringUtils.toString(t)); } channel.send(res); } catch (RemotingException e) { logger.warn("Send result to consumer failed, channel is " + channel + ", msg is " + e); } }); } catch (Throwable e) { res.setStatus(Response.SERVICE_ERROR); res.setErrorMessage(StringUtils.toString(e)); channel.send(res); } } 。。。 } 8.2.2 服务消费者 服务消费端发起请求的封装,方法执行成功后,返回一个future; final class HeaderExchangeChannel implements ExchangeChannel { 。。。 //封装请求实体 @Override public CompletableFuture<Object> request(Object request, int timeout, ExecutorService executor) throws RemotingException { 。。。 // create request. Request req = new Request(); req.setVersion(Version.getProtocolVersion()); req.setTwoWay(true); //RpcInvocation req.setData(request); DefaultFuture future = DefaultFuture.newFuture(channel, req, timeout, executor); try { channel.send(req); } catch (RemotingException e) { future.cancel(); throw e; } return future; } 。。。 } 九、传输层 9.1 做什么 抽象传输层模型,兼容netty、mina、grizzly等通讯框架。 9.2 怎么做 传输器接口定义如下,它与交换器Exchanger接口定义相似,区别在于Exchanger是围绕Dubbo的Request和Response封装的操作门面接口,而Transporter更加的底层,Exchanger用于隔离Dubbo协议层和通讯层。 @SPI("netty") public interface Transporter { @Adaptive({Constants.SERVER_KEY, Constants.TRANSPORTER_KEY}) RemotingServer bind(URL url, ChannelHandler handler) throws RemotingException; @Adaptive({Constants.CLIENT_KEY, Constants.TRANSPORTER_KEY}) Client connect(URL url, ChannelHandler handler) throws RemotingException; } 自定义传输层模型 通过SPI的方式,动态选择具体的传输框架,默认是netty; public class Transporters { 。。。 public static RemotingServer bind(URL url, ChannelHandler... handlers) throws RemotingException { 。。。 return getTransporter().bind(url, handler); } public static Client connect(URL url, ChannelHandler... handlers) throws RemotingException { 。。。 return getTransporter().connect(url, handler); } public static Transporter getTransporter() { return ExtensionLoader.getExtensionLoader(Transporter.class).getAdaptiveExtension(); } } netty框架的channel适配如下,采用装饰模式,使用netty框架的channel作为Dubbo自定义的channel做实现; final class NettyChannel extends AbstractChannel { private NettyChannel(Channel channel, URL url, ChannelHandler handler) { super(url, handler); if (channel == null) { throw new IllegalArgumentException("netty channel == null;"); } this.channel = channel; } } 十、序列化 10.1 做什么 抽象序列化模型,兼容多种序列化框架,包括:fastjson、fst、hessian2、kryo、kryo2、protobuf等,通过序列化支持跨语言的方式,支持跨语言的RPC调用。 10.2 怎么做 定义Serialization扩展点,默认hessian2,支持跨语言。Serialization接口实际是一个工厂接口,通过SPI扩展;实际序列化和反序列化工作由ObjectOutput,ObjectInput完成,通过装饰模式让hessian2完成实际工作。 @SPI("hessian2") public interface Serialization { byte getContentTypeId(); String getContentType(); @Adaptive ObjectOutput serialize(URL url, OutputStream output) throws IOException; @Adaptive ObjectInput deserialize(URL url, InputStream input) throws IOException; } 10.2.1 通讯协议设计 下图出自开发指南-实现细节-远程通讯细节,描述Dubbo协议头设计; 0-15bit表示Dubbo协议魔法数字,值:0xdabb; 16bit请求响应标记,Request - 1; Response - 0; 17bit请求模式标记,只有请求消息才会有,1表示需要服务端返回响应; 18bit是事件消息标记,1表示该消息是事件消息,比如心跳消息; 19-23bit是序列化类型标记,hessian序列化id是2,fastjson是6,详见org.apache.dubbo.common.serialize.Constants; 24-31bit表示状态,只有响应消息才有用; 32-64bit是RPC请求ID; 96-128bit是会话数据长度; 128是消息体字节序列; 十一、总结 Dubbo将RPC整个过程分成核心的代理层、注册层、集群层、协议层、传输层等,层与层之间的职责边界明确;核心层都通过接口定义,不依赖具体实现,这些接口串联起来形成了Dubbo的骨架;这个骨架也可以看作是Dubbo的内核,内核使用SPI 机制加载插件(扩展点),达到高度可扩展。 vivo互联网服务器团队-Wang Genfu

优秀的个人博客,低调大师

每日一博 | 什么是云原生区块链

这是CITA-Cloud开源项目关于「云原生与区块链」系列文章开篇,欢迎收藏关注。欢迎感兴趣的朋友不吝赐教,共同探索云链共舞的美好未来,我们非常乐意与您共同展开线上线下交流活动。 国产自主开源项目 CITA-Cloud :GitHub 地址(感谢点赞❤️) & Read the docs 区块链技术 可以打破传统商业模式中的信任边界,有效地降低跨企业协作中的摩擦成本,因此其在企业场景中的应用受到越来越多的关注。 早期的区块链项目都是公链,面向无许可的场景。一般采用PoW共识算法,具有TPS低,能耗/成本高的特点,同时治理机制复杂,功能升级要得到矿工/开发者社区的一致同意,协调难度大,时间长。因此不适用于企业应用。联盟链在其基础上,进行了一系列的技术改造,使其更加适用于企业应用。一方面将共识算法从PoW换为基于Quorum的共识,比如PBFT,减少了能耗,降低了成本,同时也能实现更高的TPS。另一个方面,参与方数量少,相对固定并且实名化之后,治理相对公链也简单了很多。 基于联盟链的企业应用大致有两类解决方案。一类在产品定位上沿袭公链的思路,作为去中心化应用平台,让企业用户以智能合约/Dapp的方式开发应用。部署方式上为私有化部署,由企业用户负责运维。另外一类是传统的企业应用大厂,从企业应用的思路出发,加入区块链的元素。比如IBM和微软提出的区块链即服务平台(BaaS),基于云平台,为企业提供开箱即用的区块链平台和端到端的解决方案。部署方式为云端集中化部署,企业用户几乎感知不到区块链的存在。 然而这两类解决方案都各自有比较严重的缺陷。对于第一类方案,联盟链的成本降低之后,企业出于隐私/安全等方面的考虑,更倾向于为每个应用设置专用的链,因此应用平台的产品定位就不成立了。而且企业用户一般已经有比较完善的IT系统,让其使用智能合约的方式开发企业应用,切换成本太高。而第二类方案,为了企业用户使用上的便利,放弃了区块链最独特的去中心化特性。这样会引发的不仅仅是隐私和数据安全的小问题,一旦终端用户开始怀疑企业对其持有的数据的掌控力,就会对整个业务逻辑产生毁灭性的打击。目前还没有一种解决方案可以完美的适应企业场景。 在本文中我们提出了一种新的产品设计思路,可以针对企业场景制定基于联盟链的解决方案,避免前面提到的各种问题。我们梳理了已有的应用案例,发现联盟链在实际的企业应用中,一般只是存储业务数据的元信息。这跟etcd在Kubernetes系统中的作用非常类似,而Kubernetes已经在企业应用中得到广泛的应用。我们分析了Kubernetes的架构,发现很多联盟链的企业应用解决方案可以借鉴的地方,且有很多组件可以直接复用。同时我们也发现Kubernetes在一些新兴的去中心化场景中,也可以从联盟链中借鉴很多东西。我们的思路是将联盟链和Kubernetes进行深度融合,保留区块链去中心化特性的同时,也达到让企业用户使用便利的目的。 现有联盟链解决方案 延续以太坊方案 现有联盟链解决方案基本上都是延续以太坊的思路。其软件栈包括:支持智能合约的底层链,各种语言的SDK,用户钱包,区块链浏览器等。 应用采用Dapp的形式,可以认为是一种B/S架构。客户端通常是一个网页,或者直接嵌入到用户钱包中。服务器被区块链代替,应用的服务端以智能合约的形式运行在区块链上,这又有点类似云原生中的Serverless方案。底层链的定位是Dapp平台,多个Dapp复用同一条链。 联盟链的创新更多的是在底层链上。替换共识算法;增加一些企业级的运维,治理,权限等功能;根据应用场景需求,增加隐私等扩展功能。 BaaS 方案 BaaS是Blockchain as a Service——区块链即服务。其主要思路是,将区块链作为一种平台,将其封装成一个PaaS系统。 BaaS屏蔽了底层联盟链的运维工作,使得创建一个链更加的简单,同时将底层链的管理功能以服务的形式暴露出来,更加方便开发者进行二次开发。其依然延续了联盟链平台化的定位,对于平台上的应用开发者而言,跟现有联盟链解决方案没有太大区别。 因为云原生技术的发展,IaaS层一般会使用Kubernetes,可以算是联盟链和云原生技术一种比较轻度的结合。 企业应用案例分析 目前我们实施过的应用案例,大致可以归为两类: 存证。企业将各种档案,公证信息等存证到联盟链系统中。联盟链可以保证这些数据不会被非法篡改,合法的修改也会有操作记录,让这些数据在多个参与方之间更可信。但是受限于存储空间和操作响应时间,一般是把原始数据哈希之后,只把哈希值存储到链上。原始数据依然存放在传统存储设备中,比如文件服务器,云盘等。原始数据的传输也是通过传统的方式进行,接收方收到原始数据之后对其做一次哈希,对比存储在链上的哈希值,以确认原始数据没有被篡改。 工作流。相当于传统ERP系统的跨企业版本。通过把传统ERP系统中的数据库替换为联盟链,利用区块链打破信任边界的特性,使得ERP系统的应用范围扩展到企业间。业务相关的功能还是由企业已有的IT系统负责,本工作流系统只负责在链上维护业务的进展和状态以及相关的事件信息。 从上述案例可以看到,用户还是会尽量复用已有的IT系统,或者依然采用传统的IT解决方案来处理复杂的企业应用。只会把少量,且必须要得到其他参与方信任的数据,比如哈希值,进度和事件信息放到链上。因此联盟链在其中的角色更像是一个分布式的元信息存储系统。 现有解决方案在这些场景的问题: 切换成本太高。如前所述,按照现有联盟链解决方案的理想情况,是希望用户完全以智能合约的方式实现业务的。这个对于一些新兴的场景,比如DeFi,因为没有历史包袱,这个解决方案是可行的。但是对于相对传统的企业来说,已经拥有比较完善的IT系统的情况下,切换成本太高。这也是为什么实际应用案例中通常不会用到复杂的智能合约,甚至都用不到智能合约。 平台化定位不成立。除非是像区块链服务网络BSN这样的机构,本身没有业务,机构定位就是做平台。对于企业用户来说,系统通常还有非常复杂的区块链之外的业务系统,联盟链在整个系统中的占比比较低。出于隐私/安全等方面的考虑,企业通常会选择私有化部署。 生态不够成熟。现有的解决方案更偏向与B/S的架构,但是在企业场景中更类似C/S的架构。类似用网页游戏的引擎来制作魔兽世界,确实太困难了。在中间件以及配套的工具上面,整个生态都不够完善和成熟。 云原生简介 云原生以微服务,容器,容器编排为主要技术,目的是构建一个完善的基础设施,可以方便的在云这种新型的动态环境中构建和部署应用。 2015 年由Google、Redhat以及微软等大型云计算厂商以及一些开源公司共同牵头成立了 CNCF—— 云原生基金会。谷歌和Redhat发布的Kubernetes 成为了CNCF托管的第一个开源项目。 CNCF成立五年来,吸引了大量企业参与,在自动化运维/容器技术/配置管理/网络/存储/安全等方面都涌现了大量优秀的项目。详情参见 CNCF landscape。 Kubernetes是一个生产级别的容器编排系统,用于自动部署,扩展和管理容器化应用程序。 Kubernetes主节点由以下几个核心组件组成: etcd保存了整个集群的状态。 apiserver提供了资源操作的唯一入口,并提供认证、授权、访问控制、API注册和发现等机制。 controller manager负责维护集群的状态,比如故障检测、自动扩展、滚动更新等。 scheduler负责资源的调度,按照预定的调度策略将Pod调度到相应的机器上。 我们可以把Kubernetes本身视作一个业务系统,其业务就是集群资源调度。那么它也是一个C/S架构,etcd加上apiserver是服务端,scheduler和controller是客户端。这部分也只是维护集群状态的元数据,真正落实业务,进行实际调度操作的是Kubelet,CRI等更边缘的组件。 联盟链与Kubernetes的比较 联盟链与Kubernetes很多方面都非常类似,但是又存在一些细节上的差异。 分布式技术 Kubernetes中的关键组件etcd使用了Raft一致性算法,跟联盟链常用的PBFT共识算法作用是类似的。 只不过Kubernetes针对的还是中心化场景,使用分布式技术的目的仅仅是为了保持系统的高可用性,因此选择的算法是非拜占庭容错的。而联盟链针对的是去中心化场景,其安全假设是不同的,可能存在恶意节点,因此选择的是拜占庭容错算法。 两种技术比较明显的区别是信任的边界不同。 etcd的节点都在一个机房内,节点间是相互信任的,其安全边界是在整个系统外面。只要通过防火墙等安全措施把入口控制,整个系统的安全问题就解决了。 联盟链的节点分散在不同的机构,节点间是相互不信任的,其安全边界在节点间。无法像中心化系统一样,通过控制入口来解决安全问题,必须使用签名等密码学方案来保证数据的安全,这其实也符合近年来提出的零信任安全。 可扩展性 联盟链一般都拥有图灵完备的虚拟机,用户可以通过智能合约扩展联盟链的功能。 接口方面,联盟链一般采用RPC的方式,只提供少量通用的接口,比如发送交易,调用合约。调用时的参数按照事先约定的格式以二进制发的方式传递,因此少量接口就可以适用于不同的合约。当然客户端就需要针对不同的合约做一些额外的处理,但是一般SDK里都提供了相应的辅助功能,比如根据合约的ABI自动生成调用的代码。 Kubernetes也是高度可扩展的。其中一种方式是Operator模式。一个Operator是由用户自定义资源(CRD)和对应的Controller软件组成的。其中CRD相当于联盟链中智能合约的存储部分,因为etcd只有存储的功能,没有执行代码的功能。对应的Controller软件则相当于智能合约代码部分的链外形式,再加上一些客户端的处理。 接口方面,Kubernetes的apiserver提供的是Restful风格的API。每个资源,都对应一个API对象,对应唯一的URL。针对用户自定义的资源,也可以通过插件的方式扩展apiserver对其进行支持。 辅助工具方面有Kubebuilder之类的工具可以生成代码并提供一些脚手架代码,简化Operator的开发。有点类似于以太坊生态中Truffle之类的框架。 治理 Kubernetes针对的还是中心化场景,只是针对多租户的场景有一些Namespace和权限的功能。etcd针对节点有一些基本的管理功能,比如增加删除节点,提供节点为投票节点等。 联盟链在治理方面的功能要强大得多。CITA针对企业场景提供了基于角色的权限系统,支持动态增加删除共识节点,还有运维相关的紧急制动和数据订正。除此之外,CITA还提供了委员会式的系统管理,每一个系统配置的修改都需要经过委员会成员的投票表决。针对有token的场景,联盟链还可以给节点运维方一些经济激励,给开发者或者早期的终端用户进行定向的补贴。 联盟链与Kubernetes融合 底层融合 随着云原生的推广,Kubernetes也碰到了一些去中心化的场景。比如,客户出于隐私/安全/不想绑定单一云供应商等因素而出现的混合云。比如监控/5G等边缘设备大量部署后出现的边缘计算。以边缘计算比较常见的云边协同场景为例。边缘设备采集数据,在云上调集大量的计算资源训练模型,然后把模型下发到边缘设备进行使用。 在这些场景中,系统的一些组件需要运行在不受控的环境中,因此就出现了零信任安全,可信计算,以及治理方面的需求。 联盟链可以很好的解决这些问题。联盟链的账户系统采用公钥体系,本身就符合零信任安全的要求。联盟链强大的治理功能也能填补Kubernetes在治理方面的缺失。最后联盟链可以视为一种基于多副本的可信计算环境。通过多个节点执行同样的任务,并以哈希的方式对比结果,只有多数节点的结果一致的情况下才能得到认可。再加上链式结构,保证历史是不可篡改的,从而保证结果是可信的。 得益于Kubernetes良好的架构,具体的融合方案都多种方式: 最简单粗暴的方式是用联盟链直接替换etcd。 Kubernetes和etcd在部署上是分开的,替换操作理论上是可行的。但是在功能上Kubernetes跟etcd是深度绑定的,依赖了很多etcd独有的特性,希望Kubernetes后续可以考虑跟etcd解耦。 这种方式的优点是:联盟链可以拿到集群所有的元信息,未来的发展空间更大;改造后Kubernetes既支持原有的中心化场景,同时也支持去中心化场景,功能几乎不受损失。 联盟链作为Controller。 这种方式类似于一些Kubernetes的跨集群方案。多个集群,每个集群里都安装同一个联盟链的一个节点,相当于在多个集群上面增加一层联盟链。 这种方式的优点是底层修改比较小,很容易实施,联盟链节点的运维可以受益于Kubernetes的功能。缺点是功能比较受限,对于应用来说,需要增加新的资源类型,应用层需要有些适配。 开放服务代理。 开放服务代理是一种API规范,它能让Kubernetes集群中运行的应用很容易的使用外部托管的的软件服务。这种方式是最简单的,双方都不需要有任何的修改,只需要进行简单的适配,就可以将联盟链的功能提供给集群中的应用,但是功能上也是受限最多的。 业务层融合 云原生以容器技术为基础,在集群资源管理和调度方面取得了巨大的成功。在更进一步的服务网格技术中,使用同样的架构,可以对集群中的应用进行更精细的流量管理。 CNCF通过社区的力量,也积累了大量的扩展组件,基本覆盖了企业应用的方方面面,其中很多已经得到广泛应用。 双方融合的解决方案可以复用云原生的软件栈,会更贴近企业应用,在易用性上优于传统的联盟链解决方案。如果企业已经使用Kubernetes,则可以大大降低企业的切换成本。 在双方融合的视角下,Kubernetes最有价值的是它针对集群调度场景抽象出了Pod,Service等资源类型。这其实对应传统联盟链解决方案中用户在智能合约中对业务进行建模的过程。双方融合之后,配合上联盟链灵活的智能合约能力,可以很容易扩展到更多的应用场景。 同时,借助联盟链去中心化的特点,可以把现有的企业应用的范围从企业内扩展到企业间,为分布式商业打下基础。 联盟链强大的治理功能,也能够将Kubernetes收集的各种度量数据利用起来,在去中心化场景中为企业间服务提供更细粒度的结算能力。 区块链在密码学和可信计算方面也有大量的技术储备,这些技术与集群管理和企业应用的结合也非常有想象空间。 总结 本文提出了将联盟链和Kubernetes深度融合的企业应用解决方案。两者强强联合,在易用性,使用成本,场景适应性方面都优于现有解决方案。 因此,在我们的下一代产品(CITA-Cloud)中,将抛弃原有的联盟链的思路和产品定位,也将抛弃SDK,钱包,区块链浏览器的工具链组合。采用本文所述方案,为企业应用场景提供更好的产品和解决方案,更好的服务企业用户。 同时我们也秉承着开放的态度,积极寻求CNCF社区的合作和交流,希望联盟链和云原生两个社区能够互相取长补短,共同进步。 作者介绍 宁志伟 溪塔科技首席架构师 首个微服务架构区块链 CITA 首席架构师,区块链+云原生框架 CITA-Cloud 设计者。前阿里巴巴、华为技术专家,超过 10 年分布式系统架构设计,编程语言和虚拟机方面工作经验。 为国产自主项目点赞👇👇👇 github.com/cita-cloud/… 如何参与开源项目 CITA-Cloud 国产自主开源云原生区块链 CITA-Cloud 目前,CITA-Cloud v1.0 版本已经发布,公司在微服务上已经做了多种实现,用不同的技术,验证了云原生区块链框架 CITA-Cloud 协议的灵活性和完善性。 在 Storage 微服务上,溪塔科技实现了封装 PingCAP 基于 tikv 的 storage_tikv、以及基于 sqlite 的 storage_sqlite 存储系统,为客户提供常用的增删改查功能。 本次版本发布,每个微服务只选择了最成熟的实现。详细微服务实现列表参见service-config.toml。 如前所述,这些实现使用了很多已有的软件或者库,是与云原生结合的体现,在此对本次发布涉及到的第三方库或者软件表示感谢:(也欢迎大家参与到国产自主开源云原生区块链 CITA-Cloud 项目中) P2P 网络库 tentacle。 文件同步软件 Syncthing。 Raft 共识算法实现 raft-rs。 数据库软件 Sqlite。 后续企业/开发者就可以只关注在某个微服务的实现上,其他微服务复用本次发布的实现。我们期待更多的企业/开发者参与其中,在使用 CITA-Cloud v0.1.0 的过程中,提出宝贵的建议和意见: 1.欢迎进入 CITA-Cloud 官网,了解项目详情 www.citahub.com/#/cita-clou… 2.通过 CITA-Cloud 项目地址,为开源项目点赞:(❤️感谢点赞❤️) github.com/cita-cloud/… 3.通过登入 CITA-Cloud 交流论坛,分享你的理解、建议以及疑惑; talk.citahub.com/c/24-catego… 4.适配 CITA-Cloud 协议,成为银河系计划中的一份子 talk.citahub.com/t/topic/174…

优秀的个人博客,低调大师

每日一博 | 教你认识 Vuex 状态机

摘要:简单来说,Vuex就是实现组件全局状态(数据)管理的一种机制,可以方便的实现组件之间数据的共享。 本文分享自华为云社区《Vuex状态机快速了解与应用》,原文作者:北极光之夜。 一. 速识概念: 1. 组件之间共享数据的方式: 通常有以下几种方式: 父向子传值:v-bind 属性绑定; 子向父传值:v-on 事件绑定; 兄弟组件之间共享数据:EventBus; 2. vuex是什么: 按照官方的话来说,Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 也集成到 Vue 的官方调试工具 devtools extension (opens new window),提供了诸如零配置的 time-travel 调试、状态快照导入导出等高级调试功能。 简单来说,Vuex就是实现组件全局状态(数据)管理的一种机制,可以方便的实现组件之间数据的共享。 3.使用vuex优点: 能够在vuex中集中管理共享的数据,易于开发和后期维护。 能够高效地实现组件之间的数据共享, 提高开发效率。 存储在vuex中的数据都是响应式的,能够实时保持数据与页面的同步。 解决了非父子组件的消息传递(将数据存放在state中)。 减少了AJAX请求次数,有些情景可以直接从内存中的state获取。 一般情况下,只有组件之间共享的数据,才有必要存储到vuex中。而对于组件中的私有数据,就没必要了,依旧存储在组件自身的data中即可。当然,如果你想要都存在vuex中也是可以的。 二. 基本使用: 1.安装依赖包: npm install vuex --save 2.导入依赖包: import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) 3.创建store对象: import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) const store = new Vuex.Store({ //state中存放的就是全局共享的数据 state: { count: 0 } }) 4. 将store对象挂载到vue实例中: new Vue({ el: '#app', store }) 此时所有组件就可以从store中获取数据了。 三.创建项目: 下面为创建一个vue项目流程,后面会有案例: (1)打开cmd窗口输入 vue ui 打开vue的可视化面板: (2)选择新建项目路径: (3)命名: (4)手动选择配置,注意用的是vue2版本: (5)创建: (6)下一步: (7)创建成功,到对应目录打开vscode开始编程: (8)运行项目: 四. 讲解前提: 前提(注意): 写一个计数器小案例,从案例中配合概念能更快上手vuex。所以下面核心概念中的代码部分是基于这个小案例来演示的。目标:写两个子组件,有一个公共count值,在父组件中,其中一个组件实现点击后count值减1,一个组件实现点击后count值增1。 父组件 App.vue 初始代码: <template> <div id="app"> <my-add></my-add> <p>--------------------</p> <my-reduce></my-reduce> </div> </template> <script> // 引入组件 import Add from './components/Add.vue' import Reduce from './components/Reduce.vue' export default { name: 'App', data() { return { } }, components: { 'my-add': Add, 'my-reduce': Reduce } } </script> 子组件Add.vue初始代码: <template> <div> <p>count值为:</p> <button>+1</button> </div> </template> <script> export default{ data() { return { } }, } </script> 子组件Reduce.vue初始代码: <template> <div> <p>count值为:</p> <button>-1</button> </div> </template> <script> export default{ data() { return { } }, } </script> store对象初始代码为: import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { count: 0 } }) 初始效果: 五.核心概念: 1.state: 按照官方的话来说,如下:Vuex 使用单一状态树——是的,用一个对象就包含了全部的应用层级状态。至此它便作为一个“唯一数据源 (SSOT)”而存在。这也意味着,每个应用将仅仅包含一个 store 实例。 简单来说,就是State提供唯一的公共数据源, 所有共享的数据都要统一放到Store的State中进行存储。 1.1 组件中访问state的第一种方式: 组件中直接输入以下命令: 如在Add.vue子组件中引用: <template> <div> <p>count值为:{{this.$store.state.count}}</p> <button>+1</button> </div> </template> //下面部分代码跟前面一样无改变,所以省略了 看效果,显示了count的值为0: 1.2 组件中访问state的第二种方式: (1)从 vuex 中按需导入 mapState 函数 import { mapState } from 'vuex' (2)通过刚才导入的mapState函数,将当前组件需要的全局数据,映射为当前组件的computed计算属性: computed: { ...mapState([count]) } 小知识:computed用来监控自己定义的变量,该变量不在data里面声明,直接在computed里面定义,然后就可以在页面上进行双向数据绑定展示出结果或者用作其他处理; 如在Reduce.vue子组件中引用: <template> <div> <p>count值为:{{count}}</p> <button>-1</button> </div> </template> <script> import {mapState} from 'vuex' export default{ data() { return { } }, computed: { ...mapState(['count']) } } </script> 看效果,同样显示了count的值为0: 2. mutation: 按照官方的话来说,更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。Vuex 中的 mutation 非常类似于事件:每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)。这个回调函数就是我们实际进行状态更改的地方,并且它会接受 state 作为第一个参数。 简单来说就是Mutation用于变更Store中的数据。 ①只能通过mutation变更Store数据,不可以直接操作Store中的数据。 ②通过这种方式虽然操作起来稍微繁琐一些,但是可以集中监控所有数据的变化。 比如,要实现count值自增加1的操作,那就在先motations里定义一个自增加1的函数。然后对应子组件想用,该组件就直接引入mutation并调用对应的函数就好。 如下,Add.vue子组件要实现自增加1功能:先在状态机里的mutations里定义一个能实现自增的函数add: export default new Vuex.Store({ state: { count: 0 }, mutations: { //自增加1函数 add(state){ state.count++ } } }) 2.1 触发mutation的第一种方式: Add.vue子组件里给按钮绑定点击事件,并触发mutation: <template> <div> <p>count值为:{{this.$store.state.count}}</p> <button @click="btnAdd">+1</button> </div> </template> <script> export default{ data() { return { } }, methods: { btnAdd() { // 第一种引入mutation的方式,触发add函数 this.$store.commit('add') } } } </script> 看效果实现了点击自增: 2.2 触发mutation并传参数: 当然,当组件里调用mutation里函数时,也是可以传参数的。比如,有一个自增函数,但增多少看调用时传入的参数: export default new Vuex.Store({ state: { count: 0 }, mutations: { // 传入参数,第一个一定是state,第二个为传入的参数 //自增加 n 的函数 addN(state,n){ state.count+= n } } }) 对应组件调用时要传入参数: methods: { btnAdd2() { // 引入mutation的方式,触发addN函数 // 并传参,自增加6吧 this.$store.commit('addN',6) } } 2.3 触发mutation的第二种方式: (1)从 vuex 中按需导入 mapMutations 函数 import { mapMutations } from 'vuex' (2)通过刚才导入的mapMutations函数,将需要的mutations函数,映射为当前组件的methods方法: methods: { ...mapMutations(['add','addN']) } 实战,实现Reduce.vue组件的点击自减1的功能要求: 状态机添加自减函数: export default new Vuex.Store({ state: { count: 0 }, mutations: { //自增加1函数 add(state){ state.count++ }, // 自减1的函数 sub(state){ state.count-- } } }) Reduce.vue组件点击按钮实现自减1: <template> <div> <p>count值为:{{count}}</p> <button @click="btnSub">-1</button> </div> </template> <script> //导入 import {mapState,mapMutations} from 'vuex' export default{ data() { return { } }, computed: { ...mapState(['count']) }, methods: { // 映射mutation里的sub函数 ...mapMutations(['sub']), // 要自减,调用sub函数 btnSub(){ this.sub() } } } </script> 看效果: 3.Action: 至此,第四大点里的案例已经完成,已经实现了自增和自减,现在对案例做改进,要我们点击按钮一秒后再自增和自减,该怎么实现?可以在状态机里的mutation里的函数是加一个1秒定时器吗,这肯定是不行的,因为mutation里不支持异步操作,那咋办,当当当,Action闪亮登场。 Action 可以包含任意异步操作,所以它用来处理异步任务。 Action 提交的是 mutation,而不是直接变更状态。记住它并不能直接修改state里的数据,只有mutation能修改。就是说,如果通过异步操作变更数据,必须通过Action,而不能使用Mutation,但是在Action中还是要通过触发Mutation的方式间接变更数据。 先在状态机里定义Action: export default new Vuex.Store({ state: { count: 0 }, mutations: { //自增加1函数 add(state){ state.count++ }, // 自减1的函数 sub(state){ state.count-- } }, // 定义action,里面的addAsync函数实现1秒后执行mutation里的add函数 actions: { addAsync(context) { setTimeout(()=>{ // 必须通过context.commit()触发mutation才行 context.commit('add') },1000) } } }) Action 函数接受一个与 store 实例具有相同方法和属性的 context 对象,因此你可以调用 context.commit 提交一个 mutation。 3.1 触发Action的第一种方式: 更改组件Add.vue代码,引入Action,实现异步自增操作。 <template> <div> <p>count值为:{{this.$store.state.count}}</p> <button @click="btnAdd">+1</button> </div> </template> <script> export default{ data() { return { } }, methods: { btnAdd() { // 第一种引入Action的方式,触发addAsync函数 // 这里的dispatch专门用来调用action函数 this.$store.dispatch('addAsync') } } } </script> 看效果,实现1秒后自增: 3.2 触发Action异步任务并传参数: 当然,当组件里调用action里函数时,也是可以传参数的。比如,有一个点击1秒后才执行的自增函数,但增多少看调用时传入的参数: 定义: export default new Vuex.Store({ state: { count: 0 }, mutations: { // 传入参数,第一个一定是state,第二个为传入的参数 //自增加 n 的函数 addN(state,n){ state.count+= n } }, actions: { // 有参数 n,这个n又传给了mutation里的addN函数 addNAsync(context,n) { setTimeout(()=>{ context.commit('addN',n) },1000) } } }) 对应组件调用时要传入参数: methods: { btnAdd2() { // 调用dispatch函数 // 触发action时传参数,为 6 吧,表示自增6 this.$store.dispatch('addNAsync',6) } } 3.3 触发Action的第二种方式: (1)从 vuex 中按需导入 mapActions 函数 import { mapActions } from 'vuex' (2)通过刚才导入的mapActions函数,将需要的actions函数,映射为当前组件的methods方法: methods: { ...mapActions(['add','addN']) } 实战,实现Reduce.vue组件的点击一秒后自减1的功能要求: 定义actions里的subAsync为一秒后自减函数: export default new Vuex.Store({ state: { count: 0 }, mutations: { //自增加1函数 add(state){ state.count++ }, // 自减1的函数 sub(state){ state.count-- } }, actions: { addAsync(context) { setTimeout(()=>{ context.commit('add') },1000) }, subAsync(context) { setTimeout(()=>{ context.commit('sub') },1000) } } }) 更改Reduce.vue代码,实现功能: <template> <div> <p>count值为:{{count}}</p> <button @click="btnSub">-1</button> </div> </template> <script> //导入 import {mapState,mapActions} from 'vuex' export default{ data() { return { } }, computed: { ...mapState(['count']) }, methods: { // 映射Action里的函数 ...mapActions(['subAsync']), // 要自减,调用subAsync函数 btnSub(){ this.subAsync() } } } </script> 看效果: 4. Getter: Getter用于对Store中的数据进行加工处理形成新的数据。且要注意的是它并不会修改state中的数据。 ①Getter 可以对Store中已有的数据加工处理之后形成新的数据,类似Vue的计算属性。 ②Store 中数据发生变化,Getter 的数据也会跟着变化。 如,有一个返回当前count+1的getter函数: 4.1 触发getters的第一种方式: this.$store.getters.名称 在App.vue组件中显示: <template> <div id="app"> <my-add></my-add> <p>--------------------</p> <my-reduce></my-reduce> <p>--------------------</p> <h3>{{this.$store.getters.showNum}}</h3> </div> </template> 效果: 4.2触发getters的第二种方式: (1)从 vuex 中按需导入 mapGetters 函数 import { mapGetters } from 'vuex' (2)通过刚才导入的mapGetters函数,将当前组件需要的全局数据,映射为当前组件的computed计算属性: computed: { ...mapGetters(['showNum']) } 还是在App.vue中使用把: <template> <div id="app"> <my-add></my-add> <p>--------------------</p> <my-reduce></my-reduce> <p>--------------------</p> <h3>{{showNum}}</h3> </div> </template> <script> // 引入组件 import Add from './components/Add.vue' import Reduce from './components/Reduce.vue' // 导入 mapGetters函数 import {mapGetters} from 'vuex' export default { name: 'App', data() { return { } }, components: { 'my-add': Add, 'my-reduce': Reduce }, // 引入 getter computed: { ...mapGetters(['showNum']) } } </script> 看,一样的效果: 点击关注,第一时间了解华为云新鲜技术~

优秀的个人博客,低调大师

每日一博 | 剖析经典论文 GBDT+LR

点击上方蓝字,关注并星标,和我一起学技术。 今天我们来剖析一篇经典的论文:Practial Lessons from Predicting Clicks on Ads at Facebook。从这篇paper的名称当中我们可以看得出来,这篇paper的作者是Facebook的广告团队。这是一篇将GBDT与LR模型结合应用在广告点击率预测的方法,虽然距今已经有好几年了,但是文中的方法仍然没有完全过时,至今依然有一些小公司还在使用。 这篇paper非常非常经典,可以说是推荐、广告领域必读的文章,说是业内的常识也不为过。这篇文章的质量很高,内容也比较基础,非常适合作为大家的入门paper。 本文有点长,建议先马后看。 简介 paper开头的部分简单介绍了一下当时互联网行业当中广告的地位以及当时Facebook的规模,当时Facebook有着7.5亿的日活(日活跃用户daily active users),超过一百万有效的广告商,因此对于Facebook来说选择合适有效的广告投放给用户的重要性是非常巨大的。在此基础上引出了Facebook的创新性做法,即将GBDT与逻辑回归模型进行组合,在真实的数据场景当中获得了超过3%的收益。 在2007年的时候Google和Yahoo就提出了在线竞价的广告收费机制,但是Facebook和搜索引擎的场景不一样,在搜索引擎的场景当中,用户会有明确的搜索意图。引擎会先根据用户的搜索意图去筛选广告,所以候选的广告集不会很大。但是Facebook不存在这样的强意图信息,所以Facebook候选的广告数量要大得多,因此对于系统的压力以及要求也要更高。 但是本文(paper)并不会讨论系统相关的内容,仅仅关注最后一部分排序模型的做法。虽然paper里没说,但是我们看得出来,Google和Yahoo这类搜索引擎当中的广告是搜索广告,而Facebook当中的这些广告是推荐广告。后者和前者最大的区别就是召回广告的逻辑不一样,有点类似于推荐系统和搜索系统的区别。 这其中的核心是用户意图,虽然用户登录Facebook的时候没有强意图,但是根据用户之前的浏览行为以及习惯,我们是可以提取出一些弱意图的。比如用户在哪类商品上停留的时间最长,点击什么样的内容的次数最多,还有类似协同过滤的把用户的行为抽象成向量的做法。其实这里面的内容很多,也非常有价值,可见Facebook在写论文的时候是留了一手的。 具体做法 说完了废话我们来看具体的做法,具体的做法很多同学可能都有所耳闻也就是GBDT+LR的做法。说起来一句话好像就说完了,但是里面的细节很多。比如为什么要用GBDT,为什么GBDT能够起作用?这里面生效的机制是什么?paper里写的内容只是表面,这些细节的思考和分析才是关键。因为paper里的做法并不是普适的,但是其中蕴含的道理往往是通用的。 首先来说说模型评估,paper当中提供了两种新的评估模型的方法。一种是Normalized Entropy,另外一种是Calibration。我们先从模型评估说起。 Normalized Entropy 这个指标在实际场景当中算是蛮常用的,经常在各种大神的代码和paper当中看到。直译过来是归一化熵的意思,意思有点变了,可以理解成归一化之后的交叉熵。它是由样本的交叉熵均值和背景CTR的交叉熵的比值计算得到的。 背景CTR指的是训练样本集样本的经验CTR,可以理解成平均的点击率。但是这里要注意,不是正负样本的比例。因为我们在训练模型之前都会做采样,比如按照正负样本比1:3采样,这里的背景CTR应该定为采样之前的比例。我们假设这个比例是p,那么我们可以写出NE的公式: 这里 的取值是 ,也就是点击是+1,没有点击是-1。这个是paper里面的公式,在实际应用当中,我们一般把click写成1,impression(没有click)写成是0。 Calibration Calibration翻译过来是校准刻度的意思,但是这里我觉得应该理解成和基准线的偏差。这个指标是模型预测的平均CTR和背景CTR的比值,这个比值越接近1,说明我们的模型的基准偏差越小,越接近真实的情况。 这个公式可以写成: AUC AUC是老生常谈了,也是我们工业界最常用的指标,几乎没有之一。AUC我们在之前的文章当中介绍过,它表示的Aera-Under-ROC,也就是ROC曲线围成的面积。ROC曲线是TPR(true postive rate)和FPR(false positive rate)组成的曲线。这个曲线面积越大表明模型区分正负样本的能力越强,在CTR排序场景当中,其实模型能否预测准确并不是最重要的,把正样本筛选出来的能力才是最重要的,这也是AUC如此重要的原因。 但是AUC也不是没有缺点,paper当中列举了一个缺点。比如说假如我们把模型预测的CTR全部x2,然后再给所有的预测结果乘上0.5来校准,这样AUC依然不会有变化。但是如果我们看NE的话,会发现NE上升了。 组合模型 终于到了重头戏了,虽然这篇paper当中讲了很多其他方面的内容,但是我们都知道GBDT+LR才是它的重点。GBDT和LR我们都很熟悉,但是它们两个怎么combine在一起呢? 其实这个问题本身就是错的,所谓GBDT+LR并不是两个模型的结合,而是一种特征的转化。也就是说这个问题我们需要从特征的角度去思考而不是从模型。 paper当中先讲了两种常用的处理特征的方法,第一种是叫做bin,也就是分桶的意思。比如说收入,这是一个连续性特征。如果我们把它放入模型,模型学到的就是它的一个权重,也就说它是线性起作用的。然而在实际的场景当中,可能这完全不是线性的。比如有钱人喜欢的品牌和穷人可能完全不同,我们希望模型能够学到非线性的效果。一种比较好的办法就是人为将这个特征进行分桶,比如根据收入分成年收入3万以下的,3万到10万的,10万到50万的,和50万以上的。落到哪个桶里,哪个桶的值标为1,否则标为0。 第二种方法叫做特征组合,这个也很好理解,比如性别是一个类别,是否是高收入群体是个类别。那我们排列组合一下,就可以得到男_低收入,男_高收入,女_低收入和女_高收入这四个类别。如果是连续性特征,可以使用像是kd-tree这类的数据结构进行离散化。我们把类别特征两两交叉就可以得到更多的特征,但这些特征并不一定都是有用的,有些可能没用,还有些可能有用但是数据很稀疏。所以虽然这样可以产生大量特征,但是需要一一手动筛选,很多是无效的。 由于手动筛选特征的工作量实在是太大,收益也不高,所以工程师就开始思考一个问题:有没有办法可以自动筛选特征呢?现在我们都知道可以让神经网络来做自动的特征交叉和筛选,但是当时神经网络还没有兴起,所以还是只能手动进行。为了解决这个问题,当时的工程师们想到了GBDT。 这才是会有GBDT+LR的原因,我们来看这张图: 我们来简单回顾一下GBDT的模型,首先GBDT是由多棵树组成的森林模型。对于每一个样本,它在预测的时候都会落到每一棵子树的其中一个叶子节点上。这样我们就可以使用GBDT来进行特征的映射,我们来看个例子: 在上图这个例子当中,GBDT一共有3棵子树,第一棵子树有3个叶子节点。我们的样本落到了第一个,所以第一棵子树对应的one-hot结果是[1, 0, 0],第二棵子树也有3个节点,样本落到了第二个节点当中,所以one-hot的结果是[0, 1, 0],同理可以得到第三棵子树的结果是[0, 0, 1, 0]。 这样我们最后把这些树的向量合并在一起,就得到了一个新的向量:[1, 0, 0, 0, 1, 0, 0, 0, 1, 0],这个向量就是我们LR模型的输入。 我们来分析一下细节,首先明确一点,GBDT只是用来进行了特征转化和处理,它的预测结果并不重要。通过使用GBDT我们同时完成了刚才提到的两种操作,既把连续性特征转换成了离散型特征,也自动完成了特征的交叉。因为对于每一棵子树而言,其实本质上是一棵CART算法实现的决策树,也就是说从树根到叶子节点的链路代表了一种潜在的规则。所以我们可以近似看成使用了GBDT代替人工进行了规则的提取以及特征的处理。 结果 最后我们来看下结果,原paper当中进行了分别进行了三组实验,分别是仅有LR、仅有GBDT和GBDT+LR进行对比。衡量的标准是各自的NE,以NE最大的Trees-only作为参考,可以发现GBDT+LR组的NE下降了3.4%。 我们贴上paper当中的结果: 怎么说呢,这个结果挺取巧的,因为对比的是NE,也就是说交叉熵下降了。但是AUC的情况不知道,paper当中没有说。并且这个下降是以NE最大的结果作为参考的,有一点点人为夸大了的感觉。当然这也是paper的常用手段,我们心里有数就好。 数据的实时性 除了模型和特征的创新之外,这篇paper还探讨了数据时效性的作用。 为了验证数据新鲜程度和模型表现之间的关系,paper选择了一条的数据用来训练了trees-only和GBDT+LR这两个模型,然后用这两个模型分别去预测之后1天到6天的数据,将整体的情况绘制成图表: 在这张图当中,横轴是预测数据距离训练数据的天数,纵轴是模型的NE。NE越低表示模型的效果越好,从上图我们可以发现第六天的结果和第0天的NE要相差大约1%。这也就意味着单纯是维持数据的新鲜度,我们就可以获得1%的提升。这也是为什么各大公司要做online-learning的原因。 online learning 在接下来的内容当中,paper还为我们介绍了一些online learning的细节性问题。其中有一些已经过时了,或者是普适性不强,我挑选了几个比较典型的有代表性的列举一下。 时间窗口 在我们搜集训练数据的过程当中,点击是比较明确的,因为点击有一个具体的行为,但是impression(曝光)不是。因为用户没点击这并不是一个行为,所以我们没办法确定用户到底是不想点还是等一会再点。比较常规的做法是维护一个定时窗口,规定一个时间,如果用户看到了广告在规定时间内没有点击,那么就认为这是一个非点击事件。 但是我们很容易发现,这么做是有问题的,因为用户可能反应迟钝,或者是暂时还没反应过来导致没有点击。就可能出现时间已经过了,用户点击了的情况。在这种情况下impression已经被记录为一个负样本了,那么点击产生的正样本就找不到对应的impression了。我们可以计算一个比例,有多少点击可以找得到impression,这个比例称为click coverage,点击率的覆盖率。 那是不是窗口时间越长越好呢?其实也不是,因为窗口过长可能会导致把一些不是点击的认为是点击。举个例子,比如说一个商品,用户第一次浏览的时候觉得不满意,所以没有点。过了一会,用户回过来看的时候又改变了主意,发生了点击。那么按照道理,我们应该把第一次没有点的行为视作是负样本,把第二次的行为认为是正样本。如果我们时间窗口设置得很长,就会被认为是一个点击样本。所以时间窗口到底应该设置得多长,这是需要调整的一个参数,不可以拍脑袋决定。 架构 streaming是业内常用的一种数据处理手段,称为流式处理。可以简单理解成队列,也就是queue。但是当发生click的时候我们需要找到对应的impression样本,将它改成正样本,所以我们还需要查找的功能。也就是说我们还需要一个hashmap来记录队列当中的节点。 当我们搜集到了足够数据,或者是搜集了规定时间的样本数据之后,我们会把窗口内的数据用来训练模型。当模型完成训练之后,会被推送到排序系统当中,实时更新排序需要调用的模型文件,从而达到在线实时训练的目的。我们来看下它的架构图: Ranker是排序系统,对候选的广告进行排序之后展示给用户,并且将这些广告的特征数据push给online joiner,也就是特征处理系统。当用户对广告进行点击之后,点击的数据也会被传入Joiner当中。Joiner会将用户的点击数据和Ranker传输来的数据进行关联,关联之后把数据传给Trainer系统进行模型的训练。 这里还涉及到一个细节,就是在joiner当中我们怎么关联呢?因为一个用户可能会有多次观看广告的数据,同一个广告也可能刷新观看多次。所以直接拿用户id或者是广告的id进行关联是不行的,我们还需要一个和时间有关的id。这个id叫做requestid,当用户每次刷新页面的时候,requestid都会刷新,这样我们就可以保证即使用户刷新网页,我们也可以正确地将数据关联上。 特征分析 最后,paper当中还附上了对特征的一些分析。虽然我们不知道它们到底都用了一些什么样的特征,但是这些分析的内容对我们依然有借鉴意义。 行为特征or上下文特征 在CTR预估的场景当中,特征主要可以分为两种,一种是上下文特征,一种是用户历史行为特征。所谓的上下文特征,其实是一个很大的概念。像是展示的广告的信息,用户本身的信息,以及当时的时间、页面内的内容、用户所在的地点等等这些当下的和场景有关的信息都可以被认为是上下文信息。历史行为特征也很好理解,也就是用户之前在平台内产生的行为。 paper当中着重分析了用户行为特征的重要性,其中的做法是将特征按照重要性倒排,然后计算在topK重要的特征当中用户历史行为特征的占比。在LR模型当中,特征的重要性就等价于对应位置的权重,得到的结果如下图: 从上图的结果我们可以看到,用户的历史行为特征在整体当中占据到了非常大的权重。top20的feature当中,只有2个是上下文特征。这也很符合我们的认知,我们投放的内容的质量远远不如用户喜欢什么重要。而用户历史上产生过行为的数据非常好地可以体现用户喜好的情况。 重要性分析 除了分析了特征的重要性之外,paper当中还实验了只使用某一种类型的特征进行预测,对比模型的性能。实验的结果如下: 上图当中红柱展示的是只使用上下文特征的训练结果,深蓝色是只使用用户行为特征的训练结果。从这个结果上我们可以明显地看到,只使用行为特征训练出来的模型性能要比使用上下文特征的好至少3.5个点,这已经是非常大的差距了。所以我们也可以得出结论,相比于上下文特征,用户的行为特征更加有用。 除此之外,paper当中还探讨了negative down sampling(负采样)和subsampling(二次采样)对于模型性能的影响。其中二次采样证明训练数据量和模型效果总体呈正比,也就是说训练数据量越大,模型的效果越好。而负采样同样对于提升模型的效果有所帮助,这也是目前广告和推荐场景下的常规手段。 今天的文章就到这里,衷心祝愿大家每天都有所收获。如果还喜欢今天的内容的话,请来一个三连支持吧~(点赞、在看、转发) - END - 本文分享自微信公众号 - TechFlow(techflow2019)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | 线程与更新 UI,细谈原理

前言 相信不少读者都阅读过相类似的文章了,但是我还是想完整的把这之间的关系梳理清楚,细节聊好,希望你也能从中学到一些。 进入正题,大家应该都听过这样一句话——“UI更新要在主线程,子线程更新UI会崩溃”。久而久之就感觉这是个真理,甚至被认为是“官方结论”。 但是如果问你,官方什么时候在哪里说过这句话,你会不会有点懵。而且就算是官方说的,也就不一定对的是吧,众所周知,Google官方文档一直都有点说的不清不楚,需要我们进行大量实践得出实际的结论。 就好比之前的Android11更新文档,我也是看了好久,通过一个个实践才写出了适配指南,然后就发现其中一个比较明显的BUG,Google官方有说过这样一句: 下面是首先需要关注的行为变更 (无论您应用的 targetSdkVersion 是多少): 外部存储访问权限 - 应用无法再访问外部存储空间中其他应用的文件。 其实经过实践会发现,外部存储访问权限还是会和targetSdkVersion有关,具体可以看这篇Android11适配指南。 废话有点多了,今天还是通过实践案例,看看这个关于线程和UI更新的 “官方结论” 正确吗? 案例一,子线程更新button文字 1)onCreate方法中更新了按钮显示文字,修改Button的宽度为固定或者wrap_content,都不崩溃。 <Buttonandroid:id="@+id/btn_ui"android:layout_width="100dp"android:layout_height="70dp"android:layout_centerInParent="true"android:text="我是一个按钮"/>//或者<Buttonandroid:id="@+id/btn_ui"android:layout_width="wrap_content"android:layout_height="70dp"android:layout_centerInParent="true"android:text="我是一个按钮"/>overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)setContentView(R.layout.activity_ui)thread{btn_ui.text="年轻人要讲武德"}} 2)onCreate方法中更新了按钮显示文字,加了延时。 Button的宽度为固定不崩溃。Button的宽度为wrap_content,崩溃报错——Only the original thread that created a view hierarchy can touch its views。 <Buttonandroid:id="@+id/btn_ui"android:layout_width="100dp"android:layout_height="70dp"android:layout_centerInParent="true"android:text="我是一个按钮"/>//或者<Buttonandroid:id="@+id/btn_ui"android:layout_width="wrap_content"android:layout_height="70dp"android:layout_centerInParent="true"android:text="我是一个按钮"/>overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)setContentView(R.layout.activity_ui)thread{Thread.sleep(3000)btn_ui.text="年轻人要讲武德"}} 案例一分析 有点懵的感觉,不慌,来看看崩溃信息。 崩溃是在按钮宽度为wrap_content,也就是根据内容设定宽度,然后3秒之后去更新按钮文字,发生了崩溃。相比之下,有两个崩溃影响点需要注意下: 宽度wrap_content。如果设置为固定值,是不会崩溃的,见案例2,所以是不是跟布局改变的逻辑有关呢? 延时3秒。如果不延时的话,即使是wrap_content也不会崩溃,见案例1,所以是不是跟某些类的加载进度有关呢? 带着这些疑问去源码中找找答案。先看看崩溃日志: android.view.ViewRootImpl$CalledFromWrongThreadException:Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.atandroid.view.ViewRootImpl.checkThread(ViewRootImpl.java:9219)atandroid.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1600)atandroid.view.View.requestLayout(View.java:24884) 可以看到是ViewRootImpl的requestLayout中检查线程的时候报错了,那我们就看看这个方法: @OverridepublicvoidrequestLayout(){if(!mHandlingLayoutInLayoutRequest){checkThread();mLayoutRequested=true;scheduleTraversals();}}voidcheckThread(){if(mThread!=Thread.currentThread()){thrownewCalledFromWrongThreadException("Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.");}} 在解开谜底之前,我们先了解下ViewRootImpl。 ViewRootImpl Activity从创建到我们看到界面,其实是经历了两个过程:加载布局和绘制。 加载布局 加载布局其实就是我们常用的setContentView(int layoutResID)方法,这个方法主要做的就是新建了一个DecorView,然后根据activity设置的主题(theme)或者特征(Feature)加载不同的根布局文件,最后再加载layoutResID资源文件。为了方便大家理解,画了一张图: 加载布局流程 这里的最后一步是调用了LayoutInflater的inflate()方法,这个方法只做了一件事,就是解析xml文件,然后根据节点生成了view对象。最后形成了一个完整的DOM结构,返回最顶层的根布局View。(DOM是一种文档对象模型,他的层次结构是除了顶级元素,所有元素都被包括到另外的元素节点中,有点像家谱树结构,很典型的就是html代码解析) 到这里,一个有完整view结构的DecorView就创建出来了,但是它还没有被绘制,也没有被显示到手机界面上。 绘制 绘制的流程发生在handleResumeActivity中,熟悉app启动流程的朋友应该知道,handleResumeActivity方法是用来触发onResume方法的,这里也完成了DecorView的绘制。再来一张图: 绘制流程 总结 由此我们可以得出一些结论:1)setContentView用来新建DecorView并加载布局的资源文件。2)onResume方法之后,会新建一个ViewRootImpl,作为DecorView的parent对DecorView进行测量,布局和绘制等操作。3)PhoneWindow作为Window的唯一子类,存储了DecorView变量,并对其进行管理,属于Activity和View交互的中间层。 分析崩溃 好了。再回来看看崩溃的原因: voidcheckThread(){if(mThread!=Thread.currentThread()){thrownewCalledFromWrongThreadException("Onlytheoriginalthreadthatcreatedaviewhierarchycantouchitsviews.");}} 可以看到是因为当前线程currentThread不是mThread的时候,就会崩溃,报的错误是 “只有创建视图层次结构的原始线程才能触摸它的视图” ,看到这里是不是猜到一些了,这个mThread难道就是“创建视图的原始线程”? 通过查找,其实这个mThread是在ViewRootImpl被创建的时候赋值的: publicViewRootImpl(Contextcontext,Displaydisplay){mThread=Thread.currentThread();} 而通过上方分析Activity加载布局过程得知,ViewRootImpl实例化发生在onResume之后,用来绘制DecorView到window上。 所以我们就可以得知崩溃的真正原因,就是当前线程不是ViewRootImpl创建时候的线程就会崩溃。翻译的还是比较准确的,只有创建视图的原始线程才能修改这个视图,听起来也蛮有道理的,我创造了你才有权利改变你,有那味了。 然后再看看前面的案例: 案例一,在onCreate中修改Button,这时候只是在修改DecorView,都没创建ViewRootImpl,也就没走到所以checkThread方法,当然不会崩溃了。ViewRootImpl的创建是在onResume之后。 案例二,延时3秒之后,界面也绘制完成了,创建ViewRootImpl显然是在主线程完成的,所以mThread为主线程,而改变Button的线程为子线程,所以setText方法会触发requestLayout方法重新绘制,最终导致崩溃。 但是,Button的宽度设置为固定值咋又不崩溃了?难道就不会执行checkThread方法了?奇怪。 找找setText的源码可以发现,有一个方法是负责检查是否需要新的布局——checkForRelayout() privatevoidcheckForRelayout(){//Ifwehaveafixedwidth,wecanjustswapinanewtextlayout//ifthetextheightstaysthesameoriftheviewheightisfixed.if((mLayoutParams.width!=LayoutParams.WRAP_CONTENT||(mMaxWidthMode==mMinWidthMode&&mMaxWidth==mMinWidth))&&(mHint==null||mHintLayout!=null)&&(mRight-mLeft-getCompoundPaddingLeft()-getCompoundPaddingRight()>0)){if(mEllipsize!=TextUtils.TruncateAt.MARQUEE){//Inafixed-heightview,souseournewtextlayout.if(mLayoutParams.height!=LayoutParams.WRAP_CONTENT&&mLayoutParams.height!=LayoutParams.MATCH_PARENT){autoSizeText();invalidate();return;}//...}//Welose:theheighthaschangedandwehaveadynamicheight.//Requestanewviewlayoutusingournewtextlayout.requestLayout();invalidate();}else{//Dynamicwidth,sowehavenochoicebuttorequestanew//viewlayoutwithanewtextlayout.nullLayouts();requestLayout();invalidate();}} 可以看到,如果布局大小没有改变的话,我们是不会去执行requestLayout方法重新进行布局绘制的,只会调用autoSizeText方法计算文字大小,invalidate绘制文字本身,所以当我们宽高设置为固定值,setText()方法就不会执行到requestLayout()方法了,自然也就执行不到checkThread()方法了。 反思 解决了问题,还需要反思下,为什么需要checkThread检查线程呢? 检查线程,其实就是检查更新UI操作的当前线程是不是当初创建UI的那个线程,这样就保证了 线程安全,因为UI控件本身不是线程安全的,但是加锁又显得太重,会降低View加载效率,毕竟是跟交互相关的。所以就直接通过判断线程这一逻辑来形成一个 单线程模型,保证View操作的线程安全。 案例二,子线程和主线程分别showToast 1)onCreate方法中弹出toast,崩溃——Can't toast on a thread that has not called Looper.prepare() overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)setContentView(R.layout.activity_ui)thread{showToast("我去年买了个表")}} 2)onCreate方法中弹出toast,增加Looper.prepare(),Looper.loop()方法。不崩溃。 加上延时3秒,不崩溃。 overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)setContentView(R.layout.activity_ui)thread{//Thread.sleep(3000)Looper.prepare()showToast("我去年买了个表")Looper.loop()}} 3)使用同一个Toast实例,在子线程中的Toast没消失之前点击按钮,在主线程中修改Toast文字并显示,则程序崩溃——Only the original thread that created a view hierarchy can touch its views.。 重新运行,在子线程中显示并消失后,点击按钮,不崩溃。 换个手机——三星s9,重新运行,在子线程中的Toast没消失之前点击按钮,不崩溃。 lateinitvarmToast:ToastoverridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)setContentView(R.layout.activity_ui)thread{Looper.prepare()mToast=Toast.makeText(this@UIMainActivity,"我去年买了个表",Toast.LENGTH_LONG)mToast.show()Looper.loop()}btn_ui.setOnClickListener{mToast.setText("我今年买了个表")mToast.show()}} 案例二分析 在解开谜底之前,我们先了解下Toast。 Toast原理 Toast.makeText(this,msg,Toast.LENGTH_SHORT).show() 简单又常用的一句代码,还是通过流程图的方式看看它是怎么创建并展示的。 Toast流程图 和DecorView加载绘制流程如出一辙,首先加载了布局文件,创建了View。然后通过addView方法,再次新建一个ViewRootImpl实例,作为parent,进行测量布局和绘制。 崩溃分析 1)首先,说下第一次崩溃——Can't toast on a thread that has not called Looper.prepare(),也就是在创建Toast的线程必须要有Looper在运行。 根据源码我们也得知Toast的显示和隐藏都是通过Handler传递消息的,所以必须要有Handler使用环境,也就是绑定Looper对象,并且通过loop方法开始循环处理消息。 2)第二次崩溃——Only the original thread that created a view hierarchy can touch its views。 这里的崩溃和之前更新Button一样的报错,所以我们有理由怀疑也是一样的原因,在不同的线程调用了ViewRootImpl的requestLayout方法。 我们看到点击按钮的时候,调用了mToast.setText()方法,咦,这不就跟案例一一模一样了吗。 setText方法中调用了TextView的setText()方法,然后由于Toast中的TextView宽高都是wrap_content的,所以会触发requestLayout方法,最后会调用到最上层View也就是ViewRootImpl的requestLayout方法。 所以崩溃的原因就是因为Toast在第一次在子线程中show的时候,新建了一个ViewRootImpl实例,绑定了当前线程也就是子线程到mThread变量。然后同一个Toast,在主线程调用setText方法,最终会调用到ViewRootImpl的requestLayout方法,引起线程检查,当前线程也就是主线程并不是当初那个创建ViewRootImpl实例的线程,所以导致崩溃。 3)那为什么等Toast消失之后,点击按钮又不崩溃了呢? 原因就在Toast的hide方法中,最终会调用到View的assignParent方法,将Toast的mParent设置为null,也就是ViewRootImpl设置为null了。所以调用setText方法的时候也就执行不到requestLayout方法了,也就不会到checkThread方法检查线程了。贴下代码: publicvoidhandleHide(){if(mView!=null){if(mView.getParent()!=null){mWM.removeViewImmediate(mView);}mView=null;}}removeViewImmediate--->removeViewLockedprivatevoidremoveViewLocked(intindex,booleanimmediate){ViewRootImplroot=mRoots.get(index);Viewview=root.getView();//...if(view!=null){view.assignParent(null);if(deferred){mDyingViews.add(view);}}}voidassignParent(ViewParentparent){if(mParent==null){mParent=parent;}elseif(parent==null){mParent=null;}else{thrownewRuntimeException("view"+this+"beingadded,but"+"italreadyhasaparent");}} 4)但是但是,为啥换个手机又不崩溃了呢? 这是我偶然发现的,在我的三星S9手机上,运行时不会崩溃的,而且界面给我的反馈并不是修改当前页面上Toast上的文字,而是像新建了一个Toast展示,即时代码中写的是setText方法。 所以我猜测在部分手机上,应该是改变了Toast的设置,当调用setText方法的时候,就会马上结束当前的Toast展示,调用hide方法。然后再进行Toast文字修改并展示,也就是刚才第三点的做法。 当然这只是我的猜测,有研究过手机源码的大神也可以补充下。 总结 任何线程都可以更新UI,也都有更新UI导致崩溃的可能。 其中的关键就是view被绘制到界面时候的线程(也就是最顶层ViewRootImpl被创建时候的线程)和进行UI更新时候的线程是不是同一个线程,如果不是就会报错。 参考 https://www.jianshu.com/p/1cdd5d1b9f3d https://www.cnblogs.com/fangg/p/12917235.html 拜拜 有一起学习的小伙伴可以关注下我的公众号——码上积木❤️❤️ 每日三问知识点/面试题,积少成多。 本文分享自微信公众号 - 码上积木(Lzjimu)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | 深入剖析共识性算法 Raft

一、 Raft简介 1.1 Raft简介 Raft是一种为了管理日志复制的分布式一致性算法。Raft出现之前,Paxos 一直是分布式一致性算法的标准。Paxos 难以理解,更难以实现。Raft 的设计目标是简化 Paxos,使得算法既容易理解,也容易实现。 Paxos 和 Raft 都是分布式一致性算法,这个过程如同投票选举领袖(Leader),参选者(Candidate)需要说服大多数投票者(Follower)投票给他,一旦选举出领袖,就由领袖发号施令。Paxos 和 Raft 的区别在于选举的具体过程不同。 Raft 可以解决分布式 CAP 理论中的 CP,即一致性(C:Consistency)和分区容忍性(P:Partition Tolerance),并不能解决可用性(A:Availability)的问题。 1.2分布一致性 分布式一致性 (distributed consensus) 是分布式系统中最基本的问题,用来保证一个分布式系统的可靠性以及容错能力。简单来说,分布式一致性是指多个服务器的保持状态一致。 在分布式系统中,可能出现各种意外(断电、网络拥塞、CPU/内存耗尽等等),使得服务器宕机或无法访问,最终导致无法和其他服务器保持状态一致。为了应对这种情况,就需要有一种一致性协议来进行容错,使得分布式系统中即使有部分服务器宕机或无法访问,整体依然可以对外提供服务。 以容错方式达成一致,自然不能要求所有服务器都达成一致状态,只要超过半数以上的服务器达成一致就可以了。假设有 N 台服务器, 大于等于 N / 2 + 1 台服务器就算是半数以上了 。 1.3复制状态机 复制状态机(Replicated State Machines)是指一组服务器上的状态机产生相同状态的副本,并且在一些机器宕掉的情况下也可以继续运行。一致性算法管理着来自客户端指令的复制日志。状态机从日志中处理相同顺序的相同指令,所以产生的结果也是相同的。 复制状态机通常都是基于复制日志实现的,如上图。每一个服务器存储一个包含一系列指令的日志,并且按照日志的顺序进行执行。每一个日志都按照相同的顺序包含相同的指令,所以每一个服务器都执行相同的指令序列。因为每个状态机都是确定的,每一次执行操作都产生相同的状态和同样的序列。 保证复制日志相同就是一致性算法的工作了。在一台服务器上,一致性模块接收客户端发送来的指令然后增加到自己的日志中去。它和其他服务器上的一致性模块进行通信来保证每一个服务器上的日志最终都以相同的顺序包含相同的请求,尽管有些服务器会宕机。一旦指令被正确的复制,每一个服务器的状态机按照日志顺序处理他们,然后输出结果被返回给客户端。因此,服务器集群看起来形成一个高可靠的状态机。 实际系统中使用的一致性算法通常含有以下特性: 安全性保证(绝对不会返回一个错误的结果):在非拜占庭错误情况下,包括网络延迟、分区、丢包、冗余和乱序等错误都可以保证正确。 可用性:集群中只要有大多数的机器可运行并且能够相互通信、和客户端通信,就可以保证可用。因此,一个典型的包含 5 个节点的集群可以容忍两个节点的失败。服务器被停止就认为是失败。他们当有稳定的存储的时候可以从状态中恢复回来并重新加入集群。 不依赖时序来保证一致性:物理时钟错误或者极端的消息延迟只有在最坏情况下才会导致可用性问题。 通常情况下,一条指令可以尽可能快的在集群中大多数节点响应一轮远程过程调用时完成。小部分比较慢的节点不会影响系统整体的性能。 1.4 RAFT应用 通过 RAFT 提供的复制状态机,可以解决分布式系统的复制、修复、节点管理等问题。Raft 极大的简化当前分布式系统的设计与实现,让开发者只关注于业务逻辑,将其抽象实现成对应的状态机即可。基于这套框架,可以构建很多分布式应用: 分布式锁服务 分布式存储系统,比如分布式消息队列、分布式块系统、分布式文件系统、分布式表格系统等,比如大名鼎鼎的 Redis 就是基于 Raft 实现分布式一致性 高可靠元信息管理,比如各类 Master 模块的 HA 二、 Raft基础 Raft 将一致性问题分解成了三个子问题:选举 Leader、日志复制、安全性。 在后续章节,会详细讲解这个子问题。现在,先了解一下 Raft 的一些核心概念。 2.1 服务器角色 在 Raft 中,任何时刻,每个服务器都处于这三个角色之一 : Leader - 领导者,通常一个系统中是一主(Leader)多从(Follower)。Leader 负责处理所有的客户端请求。 Follower - 跟随者,不会发送任何请求,只是简单的**响应来自 Leader 或者 Candidate 的请求**。 Candidate - 参选者,选举新 Leader 时的临时角色。 图示说明: Follower 只响应来自其他服务器的请求。在一定时限内,如果 Follower 接收不到消息,就会转变成 Candidate,并发起选举。 Candidate 向 Follower 发起投票请求,如果获得集群中半数以上的选票,就会转变为 Leader。 在一个 Term 内,Leader 始终保持不变,直到下线了。Leader 需要周期性向所有 Follower 发送心跳消息,以阻止 Follower 转变为 Candidate。 2.2 任期 Raft 把时间分割成任意长度的任期(Term),任期用连续的整数标记。每一段任期从一次选举开始。Raft 保证了在一个给定的任期内,最多只有一个领导者。 如果选举成功,Leader 会管理整个集群直到任期结束。 如果选举失败,那么这个任期就会因为没有 Leader 而结束。 不同服务器节点观察到的任期转换状态可能不一样: 服务器节点可能观察到多次的任期转换。 服务器节点也可能观察不到任何一次任期转换。 任期在 Raft 算法中充当逻辑时钟的作用,使得服务器节点可以查明一些过期的信息(比如过期的 Leader)。每个服务器节点都会存储一个当前任期号,这一编号在整个时期内单调的增长。当服务器之间通信的时候会交换当前任期号。 如果一个服务器的当前任期号比其他人小,那么他会更新自己的编号到较大的编号值。 如果一个 Candidate 或者 Leader 发现自己的任期号过期了,那么他会立即恢复成跟随者状态。 如果一个节点接收到一个包含过期的任期号的请求,那么他会直接拒绝这个请求。 数据可视化的应用场景越来越广泛,数据可以呈现为更多丰富的可视化形式,使用户能够更加轻易、便捷的获取并理解数据传达的信息。 2.3RPC Raft 算法中服务器节点之间的通信使用远程过程调用(RPC)。基本的一致性算法只需要两种 RPC: RequestVote RPC - 请求投票 RPC,由 Candidate 在选举期间发起。 AppendEntries RPC - 附加条目 RPC,由 Leader 发起,用来复制日志和提供一种心跳机制。 三、选举Leader 3.1 选举规则 Raft 使用一种心跳机制来触发 Leader 选举。Leader 需要周期性的向所有 Follower 发送心跳消息,以此维持自己的权威并阻止新 Leader 的产生。 每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms ~ 300ms,如果在竞选超时时间内没有收到 Leader 的心跳消息,就会认为当前 Term 没有可用的 Leader,并发起选举来选出新的 Leader。开始一次选举过程,Follower 先要增加自己的当前 Term 号,并转换为Candidate。 Candidate 会并行的向集群中的所有服务器节点发送投票请求(RequestVote RPC),它会保持当前状态直到以下三件事情之一发生: 自己成为 Leader 其他的服务器成为 Leader 没有任何服务器成为 Leader 3.1.1自己成为 Leader 当一个 Candidate 从整个集群半数以上的服务器节点获得了针对同一个 Term 的选票,那么它就赢得了这次选举并成为 Leader。每个服务器最多会对一个 Term 投出一张选票,按照先来先服务(FIFO)的原则。要求半数以上选票的规则确保了最多只会有一个 Candidate 赢得此次选举。 一旦 Candidate 赢得选举,就立即成为 Leader。然后它会向其他的服务器发送心跳消息来建立自己的权威并且阻止新的领导人的产生。 3.1.2 其他的服务器成为 Leader 等待投票期间,Candidate 可能会从其他的服务器接收到声明它是 Leader 的 AppendEntries RPC。 如果这个 Leader 的 Term 号(包含在此次的 RPC 中)不小于 Candidate 当前的 Term,那么 Candidate 会承认 Leader 合法并回到 Follower 状态。 如果此次 RPC 中的 Term 号比自己小,那么 Candidate 就会拒绝这个消息并继续保持 Candidate 状态。 3.1.3 没有任何服务器成为 Leader 如果有多个 Follower 同时成为 Candidate,那么选票可能会被瓜分以至于没有 Candidate 可以赢得半数以上的投票。当这种情况发生的时候,每一个 Candidate 都会竞选超时,然后通过增加当前 Term 号来开始一轮新的选举。然而,没有其他机制的话,选票可能会被无限的重复瓜分。 Raft 算法使用随机选举超时时间的方法来确保很少会发生选票瓜分的情况,就算发生也能很快的解决。为了阻止选票起初就被瓜分,竞选超时时间是一个随机的时间,在一个固定的区间(例如 150-300 毫秒)随机选择,这样可以把选举都分散开。 以至于在大多数情况下,只有一个服务器会超时,然后它赢得选举,成为 Leader,并在其他服务器超时之前发送心跳包。 同样的机制也被用在选票瓜分的情况下:每一个 Candidate 在开始一次选举的时候会重置一个随机的选举超时时间,然后在超时时间内等待投票的结果;这样减少了在新的选举中另外的选票瓜分的可能性。 理解了上面的选举规则后,我们通过动图来加深认识。 3.2单Candidate选举 下图表示一个分布式系统的最初阶段,此时只有 Follower,没有 Leader。Follower A 等待一个随机的选举超时时间之后,没收到 Leader 发来的心跳消息。因此,将 Term 由 0 增加为 1,转换为 Candidate,进入选举状态。 2)此时,A 向所有其他节点发送投票请求。 其它节点会对投票请求进行回复,如果超过半数以上的节点投票了,那么该 Candidate 就会立即变成 Term 为 1 的 Leader。 Leader 会周期性地发送心跳消息给所有 Follower,Follower 接收到心跳包,会重新开始计时。 3.3多 Candidate 选举 1如果有多个 Follower 成为 Candidate,并且所获得票数相同,那么就需要重新开始投票。例如下图中 Candidate B 和 Candidate D 都发起 Term 为 4 的选举,且都获得两票,因此需要重新开始投票。 2)当重新开始投票时,由于每个节点设置的随机竞选超时时间不同,因此能下一次再次出现多个 Candidate 并获得同样票数的概率很低。 四、日志复制 4.1 日志格式 **日志由含日志索引(log index)**的日志条目(log entry)组成。每个日志条目包含它被创建时的 Term 号(下图中方框中的数字),和一个复制状态机需要执行的指令。如果一个日志条目被复制到半数以上的服务器上,就被认为可以提交(Commit)了。 日志条目中的 Term 号被用来检查是否出现不一致的情况。 日志条目中的日志索引(一个整数值)用来表明它在日志中的位置。 Raft 日志同步保证如下两点;图示说明: 如果不同日志中的两个日志条目有着相同的日志索引和 Term,则它们所存储的命令是相同的。 这个特性基于这条原则:Leader 最多在一个 Term 内、在指定的一个日志索引上创建一条日志条目,同时日志条目在日志中的位置也从来不会改变。 如果不同日志中的两个日志条目有着相同的日志索引和 Term,则它们之前的所有条目都是完全一样的。 这个特性由 AppendEntries RPC 的一个简单的一致性检查所保证。在发送 AppendEntries RPC 时,Leader 会把新日志条目之前的日志条目的日志索引和 Term 号一起发送。如果 Follower 在它的日志中找不到包含相同日志索引和 Term 号的日志条目,它就会拒绝接收新的日志条目。 4.2日志复制流程 Leader 负责处理所有客户端的请求。 Leader 把请求作为日志条目加入到它的日志中,然后并行的向其他服务器发送 AppendEntries RPC 请求,要求 Follower 复制日志条目。 Follower 复制成功后,返回确认消息。 当这个日志条目被半数以上的服务器复制后,Leader 提交这个日志条目到它的复制状态机,并向客户端返回执行结果。 注意:如果 Follower 崩溃或者运行缓慢,再或者网络丢包,Leader 会不断的重复尝试发送 AppendEntries RPC 请求 (尽管已经回复了客户端),直到所有的跟随者都最终复制了所有的日志条目。 下面,通过一组动图来加深认识: 1)来自客户端的修改都会被传入 Leader。注意该修改还未被提交,只是写入日志中。 2)Leader 会把修改复制到所有 Follower。 3)Leader 会等待大多数的 Follower 也进行了修改,然后才将修改提交。 4)此时 Leader 会通知的所有 Follower 让它们也提交修改,此时所有节点的值达成一致。 4.3日志一致性 一般情况下,Leader 和 Followers 的日志保持一致,因此日志条目一致性检查通常不会失败。然而,Leader 崩溃可能会导致日志不一致:旧的 Leader 可能没有完全复制完日志中的所有条目。 4.3.1Leader 和 Follower 日志不一致的可能 Leader 和 Follower 可能存在多种日志不一致的可能。 图示说明:上图阐述了 Leader 和 Follower 可能存在多种日志不一致的可能,每一个方框表示一个日志条目,里面的数字表示任期号 。 当一个 Leader 成功当选时,Follower 可能出现以下情况(a-f): 存在未更新日志条目,如(a、b)。 存在未提交日志条目,如(c、d)。 或两种情况都存在,如(e、f)。 例如,场景 f 可能会这样发生,某服务器在 Term2 的时候是 Leader,已附加了一些日志条目到自己的日志中,但在提交之前就崩溃了;很快这个机器就被重启了,在 Term3 重新被选为 Leader,并且又增加了一些日志条目到自己的日志中;在 Term 2 和 Term 3 的日志被提交之前,这个服务器又宕机了,并且在接下来的几个任期里一直处于宕机状态。 4.3.2Leader 和 Follower 日志一致的保证 Leader 通过强制 Followers 复制它的日志来处理日志的不一致,Followers 上的不一致的日志会被 Leader 的日志覆盖。 Leader 为了使 Followers 的日志同自己的一致,Leader 需要找到 Followers 同它的日志一致的地方,然后覆盖 Followers 在该位置之后的条目。 Leader 会从后往前试,每次日志条目失败后尝试前一个日志条目,直到成功找到每个 Follower 的日志一致位点,然后向后逐条覆盖 Followers 在该位置之后的条目。 五、安全性 前面描述了 Raft 算法是如何选举 Leader 和复制日志的。 Raft 还增加了一些限制来完善 Raft 算法,以保证安全性:保证了任意 Leader 对于给定的 Term,都拥有了之前 Term 的所有被提交的日志条目。 5.1选举限制 拥有最新的已提交的日志条目的 Follower 才有资格成为 Leader。 Raft 使用投票的方式来阻止一个 Candidate 赢得选举除非这个 Candidate 包含了所有已经提交的日志条目。Candidate 为了赢得选举必须联系集群中的大部分节点,这意味着每一个已经提交的日志条目在这些服务器节点中肯定存在于至少一个节点上。如果 Candidate 的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么他一定持有了所有已经提交的日志条目。 RequestVote RPC实现了这样的限制:RequestVote RPC 中包含了 Candidate 的日志信息, Follower 会拒绝掉那些日志没有自己新的投票请求。 5.1.1 场如何判断哪个日志条目比较新? Raft 通过比较两份日志中最后一条日志条目的日志索引和 Term 来判断哪个日志比较新。 先判断 Term,哪个数值大即代表哪个日志比较新。 如果 Term 相同,再比较 日志索引,哪个数值大即代表哪个日志比较新。 5.1.2提交旧任期的日志条目 一个当前 Term 的日志条目被复制到了半数以上的服务器上,Leader 就认为它是可以被提交的。如果这个 Leader 在提交日志条目前就下线了,后续的 Leader 可能会覆盖掉这个日志条目。 **图示说明:**上图解释了为什么 Leader 无法对旧 Term 的日志条目进行提交。 阶段 (a),S1 是 Leader,且 S1 写入日志条目为 (Term 2,日志索引 2),只有 S2 复制了这个日志条目。 阶段 (b),S1 下线,S5 被选举为 Term3 的 Leader。S5 写入日志条目为 (Term 3,日志索引 2)。 阶段 (c),S5 下线,S1 重新上线,并被选举为 Term4 的 Leader。此时,Term 2 的那条日志条目已经被复制到了集群中的大多数节点上,但是还没有被提交。 阶段 (d),S1 再次下线,S5 重新上线,并被重新选举为 Term3 的 Leader。然后 S5 覆盖了日志索引 2 处的日志。 阶段 (e),如果阶段 (d) 还未发生,即 S1 再次下线之前,S1 把自己主导的日志条目复制到了大多数节点上,那么在后续 Term 里面这些新日志条目就会被提交。这样在同一时刻就同时保证了,之前的所有旧日志条目就会被提交。 Raft 永远不会通过计算副本数目的方式去提交一个之前 Term 内的日志条目。只有 Leader 当前 Term 里的日志条目通过计算副本数目可以被提交;一旦当前 Term 的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。 当 Leader复制之前任期里的日志时,Raft 会为所有日志保留原始的 Term,这在提交规则上产生了额外的复杂性。在其他的一致性算法中,如果一个新的领导人要重新复制之前的任期里的日志时,它必须使用当前新的任期号。 Raft 使用的方法更加容易辨别出日志,因为它可以随着时间和日志的变化对日志维护着同一个任期编号。另外,和其他的算法相比,Raft 中的新领导人只需要发送更少日志条目(其他算法中必须在他们被提交之前发送更多的冗余日志条目来为他们重新编号)。 六、日志压缩 在实际的系统中,不能让日志无限膨胀,否则系统重启时需要花很长的时间进行恢复,从而影响可用性。Raft 采用对整个系统进行快照来解决,快照之前的日志都可以丢弃。 每个副本独立的对自己的系统状态生成快照,并且只能对已经提交的日志条目生成快照。快照包含以下内容: 日志元数据。最后一条已提交的日志条目的日志索引和 Term。这两个值在快照之后的第一条日志条目的 AppendEntries RPC 的完整性检查的时候会被用上。 系统当前状态。 当 Leader 要发送某个日志条目,落后太多的 Follower 的日志条目会被丢弃,Leader 会将快照发给 Follower。或者新上线一台机器时,也会发送快照给它。 生成快照的频率要适中,频率过高会消耗大量 I/O 带宽;频率过低,一旦需要执行恢复操作,会丢失大量数据,影响可用性。推荐当日志达到某个固定的大小时生成快照。生成一次快照可能耗时过长,影响正常日志同步。可以通过使用 copy-on-write 技术避免快照过程影响正常日志同步。 说明:本文仅阐述 Raft 算法的核心内容,不包括算法论证、评估等 七、参考资料 1.Raft 一致性算法论文原文 2.Raft 一致性算法论文译文 3.Raft 作者讲解视频 4.Raft 作者讲解视频对应的 PPT 5.分布式系统的 Raft 算法 6.Raft 算法详解 7.Raft:Understandable DistributedConsensus 8.sofa-jraft - 蚂蚁金服的 Raft 算法实现库(Java 版 ​作者:vivo互联网服务器团队-ZhangPeng

优秀的个人博客,低调大师

每日一博 | 浅谈 React 中的 XSS 攻击

👆 这是第 70 篇 不掺水的原创 ,想要了解更多 ,请戳上方蓝色字体: 政采云前端团队 关注我们吧~ 本文首发于政采云前端团队博客:浅谈 React 中的 XSS 攻击 https://www.zoo.team/article/xss-in-react 前言 前端一般会面临 XSS 这样的安全风险,但随着 React 等现代前端框架的流行,使我们在平时开发时不用太关注安全问题。以 React 为例,React 从设计层面上就具备了很好的防御 XSS 的能力。本文将以源码角度,看看 React 做了哪些事情来实现这种安全性的。 XSS 攻击是什么 Cross-Site Scripting(跨站脚本攻击)简称 XSS,是一种代码注入攻击。XSS 攻击通常指的是利用网页的漏洞,攻击者通过巧妙的方法注入 XSS 代码到网页,因为浏览器无法分辨哪些脚本是可信的,导致 XSS 脚本被执行。XSS 脚本通常能够窃取用户数据并发送到攻击者的网站,或者冒充用户,调用目标网站接口并执行攻击者指定的操作。 XSS 攻击类型 反射型 XSS XSS 脚本来自当前 HTTP 请求 当服务器在 HTTP 请求中接收数据并将该数据拼接在 HTML 中返回时,例子: //某网站具有搜索功能,该功能通过 URL 参数接收用户提供的搜索词: https://xxx.com/search?query=123 //服务器在对此 URL 的响应中回显提供的搜索词: <p>您搜索的是:123</p> //如果服务器不对数据进行转义等处理,则攻击者可以构造如下链接进行攻击: https://xxx.com/search?query=<imgsrc="empty.png"onerror="alert('xss')"> //该 URL 将导致以下响应,并运行 alert('xss'): <p>您搜索的是:<imgsrc="empty.png"onerror="alert('xss')"></p> //如果有用户请求攻击者的 URL ,则攻击者提供的脚本将在用户的浏览器中执行。 存储型 XSS XSS 脚本来自服务器数据库中 攻击者将恶意代码提交到目标网站的数据库中,普通用户访问网站时服务器将恶意代码返回,浏览器默认执行,例子: //某个评论页,能查看用户评论。 //攻击者将恶意代码当做评论提交,服务器没对数据进行转义等处理 //评论输入: <textarea> <imgsrc="empty.png"onerror="alert('xss')"> </textarea> //则攻击者提供的脚本将在所有访问该评论页的用户浏览器执行 DOM 型 XSS 该漏洞存在于客户端代码,与服务器无关 类似反射型,区别在于 DOM 型 XSS 并不会和后台进行交互,前端直接将 URL 中的数据不做处理并动态插入到 HTML 中,是纯粹的前端安全问题,要做防御也只能在客户端上进行防御。 React 如何防止 XSS 攻击 无论使用哪种攻击方式,其本质就是将恶意代码注入到应用中,浏览器去默认执行。React 官方中提到了 React DOM 在渲染所有输入内容之前,默认会进行转义。它可以确保在你的应用中,永远不会注入那些并非自己明确编写的内容。所有的内容在渲染之前都被转换成了字符串,因此恶意代码无法成功注入,从而有效地防止了 XSS 攻击。我们具体看下: 自动转义 React 在渲染 HTML 内容和渲染 DOM 属性时都会将 "'&<> 这几个字符进行转义,转义部分源码如下: for(index=match.index;index<str.length;index++){ switch(str.charCodeAt(index)){ case34://" escape='&quot;'; break; case38://& escape='&amp;'; break; case39://' escape='&#x27;'; break; case60://< escape='&lt;'; break; case62://> escape='&gt;'; break; default: continue; } } 这段代码是 React 在渲染到浏览器前进行的转义,可以看到对浏览器有特殊含义的字符都被转义了,恶意代码在渲染到 HTML 前都被转成了字符串,如下: //一段恶意代码 <imgsrc="empty.png"onerror="alert('xss')"> //转义后输出到html中 &lt;imgsrc=&quot;empty.png&quot;onerror=&quot;alert(&#x27;xss&#x27;)&quot;&gt; 这样就有效的防止了 XSS 攻击。 JSX 语法 JSX 实际上是一种语法糖,Babel 会把 JSX 编译成 React.createElement() 的函数调用,最终返回一个 ReactElement,以下为这几个步骤对应的代码: //JSX constelement=( <h1className="greeting"> Hello,world! </h1> ); //通过babel编译后的代码 constelement=React.createElement( 'h1', {className:'greeting'}, 'Hello,world!' ); //React.createElement()方法返回的ReactElement constelement={ $$typeof:Symbol('react.element'), type:'h1', key:null, props:{ children:'Hello,world!', className:'greeting' } ... } 我们可以看到,最终渲染的内容是在 Children 属性中,那了解了 JSX 的原理后,我们来试试能否通过构造特殊的 Children 进行 XSS 注入,来看下面一段代码: conststoredData=`{ "ref":null, "type":"body", "props":{ "dangerouslySetInnerHTML":{ "__html":"<imgsrc=\"empty.png\"onerror=\"alert('xss')\"/>" } } }`; //转成JSON constparsedData=JSON.parse(storedData); //将数据渲染到页面 render(){ return<span>{parsedData}</span>; } 这段代码中, 运行后会报以下错误,提示不是有效的 ReactChild Uncaught(inpromise)Error:ObjectsarenotvalidasaReactchild(found:objectwithkeys{ref,type,props}).Ifyoumeanttorenderacollectionofchildren,useanarrayinstead. 那究竟是哪里出问题了?我们看一下 ReactElement 的源码: constsymbolFor=Symbol.for; REACT_ELEMENT_TYPE=symbolFor('react.element'); constReactElement=function(type,key,ref,self,source,owner,props){ constelement={ //这个tag唯一标识了此为ReactElement $$typeof:REACT_ELEMENT_TYPE, //元素的内置属性 type:type, key:key, ref:ref, props:props, //记录创建此元素的组件 _owner:owner, }; ... returnelement; } 注意到其中有个属性是 $$typeof,它是用来标记此对象是一个 ReactElement,React 在进行渲染前会通过此属性进行校验,校验不通过将会抛出上面的错误。React 利用这个属性来防止通过构造特殊的 Children 来进行的 XSS 攻击,原因是 $$typeof 是个 Symbol 类型,进行 JSON 转换后会 Symbol 值会丢失,无法在前后端进行传输。如果用户提交了特殊的 Children,也无法进行渲染,利用此特性,可以防止存储型的 XSS 攻击。 在 React 中可引起漏洞的一些写法 使用 dangerouslySetInnerHTML dangerouslySetInnerHTML 是 React 为浏览器 DOM 提供 innerHTML 的替换方案。通常来讲,使用代码直接设置 HTML 存在风险,因为很容易使用户暴露在 XSS 攻击下,因为当使用 dangerouslySetInnerHTML 时,React 将不会对输入进行任何处理并直接渲染到 HTML 中,如果攻击者在 dangerouslySetInnerHTML 传入了恶意代码,那么浏览器将会运行恶意代码。看下源码: functiongetNonChildrenInnerMarkup(props){ constinnerHTML=props.dangerouslySetInnerHTML;//有dangerouslySetInnerHTML属性,会不经转义就渲染__html的内容 if(innerHTML!=null){ if(innerHTML.__html!=null){ returninnerHTML.__html; } }else{ constcontent=props.children; if(typeofcontent==='string'||typeofcontent==='number'){ returnescapeTextForBrowser(content); } } returnnull; } 所以平时开发时最好避免使用 dangerouslySetInnerHTML,如果不得不使用的话,前端或服务端必须对输入进行相关验证,例如对特殊输入进行过滤、转义等处理。前端这边处理的话,推荐使用白名单过滤(https://jsxss.com/zh/index.html),通过白名单控制允许的 HTML 标签及各标签的属性。 通过用户提供的对象来创建 React 组件 举个例子: //用户的输入 constuserProvidePropsString=`{"dangerouslySetInnerHTML":{"__html":"<imgonerror='alert(\"xss\");'src='empty.png'/>"}}"`; //经过JSON转换 constuserProvideProps=JSON.parse(userProvidePropsString); //userProvideProps={ //dangerouslySetInnerHTML:{ //"__html":`<imgonerror='alert("xss");'src='empty.png'/>` //} //}; render(){ //出于某种原因解析用户提供的JSON并将对象作为props传递 return<div{...userProvideProps}/> } 这段代码将用户提供的数据进行 JSON 转换后直接当做 div 的属性,当用户构造了类似例子中的特殊字符串时,页面就会被注入恶意代码,所以要注意平时在开发中不要直接使用用户的输入作为属性。 使用用户输入的值来渲染 a 标签的 href 属性,或类似 img 标签的 src 属性等 constuserWebsite="javascript:alert('xss');"; <ahref={userWebsite}></a> 如果没有对该 URL 进行过滤以防止通过 javascript: 或 data: 来执行 JavaScript,则攻击者可以构造 XSS 攻击,此处会有潜在的安全问题。用户提供的 URL 需要在前端或者服务端在入库之前进行验证并过滤。 服务端如何防止 XSS 攻击 服务端作为最后一道防线,也需要做一些措施以防止 XSS 攻击,一般涉及以下几方面: 在接收到用户输入时,需要对输入进行尽可能严格的过滤,过滤或移除特殊的 HTML 标签、JS 事件的关键字等。 在输出时对数据进行转义,根据输出语境 (html/javascript/css/url),进行对应的转义 对关键 Cookie 设置 http-only 属性,JS脚本就不能访问到 http-only 的 Cookie 了 利用 CSP (https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP) 来抵御或者削弱 XSS 攻击,一个 CSP 兼容的浏览器将会仅执行从白名单域获取到的脚本文件,忽略所有的其他脚本 (包括内联脚本和 HTML 的事件处理属性) 总结 出现 XSS 漏洞本质上是输入输出验证不充分,React 在设计上已经很安全了,但是一些反模式的写法还是会引起安全漏洞。Vue 也是类似,Vue 做的安全措施主要也是转义,HTML 的内容和动态绑定的属性都会进行转义。无论使用 React 或 Vue 等前端框架,都不能百分百的防止 XSS 攻击,所以服务端必须对前端参数做一些验证,包括但不限于特殊字符转义、标签、属性白名单过滤等。一旦出现安全问题一般都是挺严重的,不管是敏感数据被窃取或者用户资金被盗,损失往往无法挽回。我们平时开发中需要保持安全意识,保持代码的可靠性和安全性。 小游戏 看完文章可以尝试下 XSS 的小游戏(https://xss-game.appspot.com/),自己动手实践模拟 XSS 攻击,可以对 XSS 有更进一步的认识。 看完两件事 如果你觉得这篇内容对你挺有启发,我想邀请你帮我两件小事 1.点个「 在看」,让更多人也能看到这篇内容(点了 「 在看 」,bug -1 😊) 2.关注公众号「 政采云前端团队」,持续为你推送精选好文 本文分享自微信公众号 - 政采云前端团队(Zoo-Team)。 如有侵权,请联系 support@oschina.cn 删除。 本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | 深度学习背后的数学思想

深度神经网络(DNN)本质上是通过具有多个连接的感知器而形成的,其中感知器是单个神经元。可以将人工神经网络(ANN)视为一个系统,其中包含沿加权路径馈入的一组输入。然后处理这些输入,并产生输出以执行某些任务。随着时间的流逝,ANN“学习”了,并且开发了不同的路径。各种路径可能具有不同的权重,并且在模型中,比那些产生较少的理想结果的路径,以及被发现更重要(或产生更理想的结果)的路径分配了更高的权重。 在DNN中,如果所有输入都密集连接到所有输出,则这些层称为密集层。此外,DNN可以包含多个隐藏层。隐藏层基本上是神经网络输入和输出之间的点,激活函数对输入的信息进行转换。之所以称其为隐藏层,是因为无法从系统的输入和输出中直接观察到这一点。神经网络越深,网络可以从数据中识别的越多,输出的信息越多。 但是,尽管目标是从数据中尽可能多地学习,但是深度学习模型可能会遭受过度拟合的困扰。当模型从训练数据(包括随机噪声)中学习太多时,就会发生这种情况。然后,模型可以确定数据中非常复杂的模式,但这会对新数据的性能产生负面影响。训练数据中拾取的噪声不适用于新数据或看不见的数据,并且该模型无法概括发现的模式。非线性模型在深度学习模型中也非常重要,尽管该模型将从具有多个隐藏层的内容中学到很多东西,但是将线性形式应用于非线性问题将导致性能下降。 现在的问题是,“这些层如何学习东西?” 好吧,我们可以在这里将ANN应用于实际场景以解决问题并了解如何训练模型以实现其目标。案例分析如下: 在当前的大流行中,许多学校已经过渡到虚拟学习,这使一些学生担心他们通过课程的机会。“我将通过本课程”这个问题是任何人工智能系统都应该能够解决的问题。 为简单起见,让我们考虑该模型只有3个输入:学生参加的讲座的数量,在作业上花费的时间以及整个讲座中互联网连接丢失的次数。该模型的输出将是二进制分类。学生要么通过了课程,要么没有通过,其实就是0和1。现在到了学期期末,学生A参加了21堂课,花了90个小时进行作业,并且在整个学期中有7次失去互联网连接。这些输入信息被输入到模型中,并且输出预测学生有5%的机会通过课程。一周后,发布了最终成绩,学生A通过了该课程。那么,模型的预测出了什么问题? 从技术上讲,没有任何问题。该模型本来可以按目前开发的方式工作。问题在于该模型不知道发生了什么。我们将在路径上初始化一些权重,但是该模型当前不知道对与错。因此,权重不正确。这就是学习的主要源头,其中的想法是模型需要掌握错误的时间的规律,我们通过计算某种形式的“损失”来做到这一点。计算得出的损失取决于当前的问题,但是通常会涉及使预测输出与实际输出之间的差异最小化。 在上述情况下,只有一名学生和一个错误点可以减少到最小。但是,通常不是这种情况。现在,如果考虑将多个学生和多个差异最小化,那,总损失通常将计算为所有预测和实际观察值之间的差异的平均值。 回想一下,正在计算的损失取决于当前的问题。因此,由于我们当前的问题是二元分类(0和1分类),因此适当的损失计算将是交叉熵损失,该功能背后的想法是,它比较学生是否将通过课程的预测分布与实际分布,并尝试最小化这些分布之间的差异。 取而代之的是,我们不再希望预测学生是否会通过该课程,而是希望预测他们将在该课程中获得的分数。因此,交叉熵损失将不再是一种合适的方法。相反,均方误差损失将更合适。此方法适用于回归问题,其思想是将尝试最小化实际值和预测值之间的平方差。 现在我们了解了一些损失函数(这里有损失函数的介绍:深度学习基础:数学分析基础与Tensorflow2.0回归模型 文章末尾可下载PDF书籍),我们可以进行损失优化和模型训练。拥有良好DNN的关键因素是拥有适当的权重。损耗优化应尝试找到一组权重W,以最小化计算出的损耗。如果只有一个重量分量,则可以在二维图上绘制重量和损耗,然后选择使损耗最小的重量。但是,大多数DNN具有多个权重分量,并且可视化n维图非常困难。 取而代之的是,针对所有权重计算损失函数的导数,以确定最大上升的方向。现在,模型可以理解向上和向下的方向,然后向下移动,直到达到局部最小值的收敛点。完成这一体面操作后,将返回一组最佳权重,这就是DNN应该使用的权重(假设模型开发良好的话)。 计算此导数的过程称为反向传播,它本质上是来自微积分的链式法则。考虑上面显示的神经网络,第一组权重的微小变化如何影响最终损失?这就是导数或梯度试图解释的内容。但是,第一组权重被馈送到隐藏层,然后隐藏层又具有另一组权重,从而导致预测的输出和损失。因此,还应考虑权重变化对隐藏层的影响。现在,这些是网络中仅有的两个部分。但是,如果要考虑的权重更多,则可以通过应用从输出到输入的链式规则来继续此过程。 训练DNN时要考虑的另一个重要因素是学习率(可以看做是数学中的收敛因子)。当模型行进以找到最佳的权重集时,它需要以某种因素来更新其权重。尽管这似乎微不足道,但是确定模型移动的因素非常非常必要。如果因子太小,则该模型可以运行一段指数级的长时间,也可以陷入非全局最小值的某个位置。如果因数太大,则模型可能会完全错过目标点,然后发散。 尽管固定比率可能是理想的,但自适应学习比率会减少前面提到的问题的机会。也就是说,该系数将根据当前梯度,当前权重的大小或其他可能影响模型下一步来寻找最佳权重的地方而变化。 可以看出,DNN是基于微积分和一些统计数据构建的。评估这些深度技术过程背后的数学思想是有用的,因为它可以帮助人们了解模型中真正发生的事情,并且可以导致整体上开发出更好的模型。但是,即使不容易理解这些概念,对于大多数深度模型算法程序也会附带自动区分等工具,不过,如果你想提高你的技术能力,保持学习才是你屹立在工作岗位上的最好的建议,编程愉快! 参考文献: 参考文献及作者Trist'nJosephdigitaltrends.com/cool-tech/what-is-an-artificial-neural-network/deepai.org/machine-learning-glossary-and-terms/hidden-layer-machine-learning#:~:text=In%20neural%20networks%2C%20a%20hidden,input%20enter%20into%20the%20network。ncbi.nlm.nih.gov/pmc/articles/PMC4960264/、、、datascience.com/introduction-to-artificial-neural-networks-ann-1aea15775ef9说明thatstuff.com/introduction-to-neural-networks.htmlneuronetworksanddeeplearning.com/mathsisfun.com/calculus/derivatives-rules.htmld2l.ai/chapter_appendix-mathematics-for-deep-learning/multivariable-calculus.html 其他引用材料: deeplearning.mit.edu/math.ucdavis.edu/~kouba/CalcOneDIRECTORY/chainruledirectory/ChainRule.htmlyoutube.com/watch?v=tGVnBAHLApAhttps://www.inertia7.com/tristnyoutube.com/watch?v=aircAruvnKkyoutube.com/watch?v=bfmFfD2RIcghttps://towardsdatascience.com/what-is-deep-learning-adf5d4de9afc 本文分享自微信公众号 - AI科技与算法编程(kangsinx)。如有侵权,请联系 support@oschina.cn 删除。本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

优秀的个人博客,低调大师

每日一博 | RocketMQ 基础概念剖析&源码解析

Topic Topic是一类消息的集合,是一种逻辑上的分区。为什么说是逻辑分区呢?因为最终数据是存储到Broker上的,而且为了满足高可用,采用了分布式的存储。 这和Kafka中的实现如出一辙,Kafka的Topic也是一种逻辑概念,每个Topic的数据会分成很多份,然后存储在不同的Broker上,这个「份」叫Partition。而在RocketMQ中,Topic的数据也会分布式的存储,这个「份」叫MessageQueue。 其分布可以用下图来表示。 这样一来,如果某个Broker所在的机器意外宕机,而且刚好MessageQueue中的数据还没有持久化到磁盘,那么该Topic下的这部分消息就会完全丢失。此时如果有备份的话,MQ就可以继续对外提供服务。 为什么还会出现没有持久化到磁盘的情况呢?现在的OS当中,程序写入数据到文件之后,并不会立马写入到磁盘,因为磁盘I/O是非常耗时的操作,在计算机来看是非常慢的一种操作。所以写入文件的数据会先写入到OS自己的缓存中去,然后择机异步的将Buffer中的数据刷入磁盘。 通过多副本冗余的机制,使得RocketMQ具有了高可用的特性。除此之外,分布式存储能够应对后期业务大量的数据存储。如果不使用分布式进行存储,那么随着后期业务发展,消息量越来越大,单机是无论如何也满足不了RocketMQ消息的存储需求的。如果不做处理,那么一台机器的磁盘总有被塞满的时候,此时的系统就不具备可伸缩的特性,也无法满足业务的使用要求了。 但是这里的可伸缩,和微服务中的服务可伸缩还不太一样。因为在微服务中,各个服务是无状态的。而Broker是有状态的,每个Broker上存储的数据都不太一样,因为Producer在发送消息的时候会通过指定的算法,从Message Queue列表中选出一个MessageQueue发送消息。 如果不是很理解这个横向扩展,那么可以把它当成Redis的Cluster,通过一致性哈希,选择到Redis Cluster中的具体某个节点,然后将数据写入Redis Master中去。如果此时想要扩容很方便,只需要往Redis Cluster中新增Master节点就好了。 所以,数据分布式的存储本质上是一种数据分片的机制。在此基础上,通过冗余多副本,达成了高可用。 Broker Broker可以理解为我们微服务中的一个服务的某个实例,因为微服务中我们的服务一般来说都会多实例部署,而RocketMQ也同理,多实例部署可以帮助系统扛住更多的流量,也从某种方面提高了系统的健壮性。 在RocketMQ4.5之前,它使用主从架构,每一个Master Broker都有一个自己的Slave Broker。 那RocketMQ的主从Broker是如何进行数据同步的呢? Broker启动的时候,会启动一个定时任务,定期的从Master Broker同步全量的数据。 这块可以先不用纠结,后面我们会通过源码来验证这个主从同步逻辑。 上面提到了Broker会部署很多个实例,那么既然多实例部署,那必然会存在一个问题,客户端是如何得知自己是连接的哪个服务器?如何得知对应的Broker的IP地址和端口?如果某个Broker突然挂了怎么办? NameServer 这就需要NameServer了,NameServer是什么? 这里先拿Spring Cloud举例子——Spring Cloud中服务启动的时候会将自己注册到Eureka注册中心上。当服务实例启动的时候,会从Eureka拉取全量的注册表,并且之后定期的从Eureka增量同步,并且每隔30秒发送心跳到Eureka去续约。如果Eureka检测到某个服务超过了90秒没有发送心跳,那么就会该服务宕机,就会将其从注册表中移除。 RocketMQ中,NameServer充当的也是类似的角色。两者从功能上也有一定的区别。 Broker在启动的时候会向NameServer注册自己,并且每隔30秒向NameServerv发送心跳。如果某个Broker超过了120秒没有发送心跳,那么就会认为该Broker宕机,就会将其从维护的信息中移除。这块后面也会从源码层面验证。 当然NameServer不仅仅是存储了各个Broker的IP地址和端口,还存储了对应的Topic的路由数据。什么是路由数据呢?那就是某个Topic下的哪个Message Queue在哪台Broker上。 Producer 总体流程 接下来,我们来看看Producer发送一条消息到Broker的时候会做什么事情,整体的流程如下。 检查消息合法性 整体来看,其实是个很简单的操作,跟我们平时写代码是一样的,来请求了先校验请求是否合法。Producer启动这里会去校验当前Topic数据的合法性。 Topic名称中是否包含了非法字符 Topic名称长度是否超过了最大的长度限制,由常量TOPIC_MAX_LENGTH来决定,其默认值为127 当前消息体是否是NULL或者是空消息 当前消息体是否超过了最大限制,由常量maxMessageSize决定,值为1024 * 1024 * 4,也就是4M。 都是些很常规的操作,和我们平时写的checker都差不多。 获取Topic的详情 当通过了消息的合法性校验之后,就需要继续往下走。此时的关注点就应该从消息是否合法转移到我要发消息给谁。 此时就需要通过当前消息所属的Topic拿到Topic的详细数据。 获取Topic的方法源码在上面已经给出来了,首先会从内存中维护的一份Map中获取数据。顺带一提,这里的Map是ConcurrentHashMap,是线程安全的,和Golang中的Sync.Map类似。 当然,首次发送的话,这个Map肯定是空的,此时会调用NameServer的接口,通过Topic去获取详情的Topic数据,此时会在上面的方法中将其加入到Map中去,这样一来下次再往该Topic发送消息就能够直接从内存中获取。这里就是简单的实现的缓存机制 。 从方法名称来看,是通过Topic获取路由数据。实际上该方法,通过调用NameServer提供的API,更新了两部分数据,分别是: Topic路由信息 Topic下的Broker相关信息 而这两部分数据都来源于同一个结构体TopicRouteData。其结构如下。 通过源码可以看到,就包含了该Topic下所有Broker下的Message Queue相关的数据、所有Broker的地址信息。 发送的具体Queue 此时我们获取到了需要发送到的Broker详情,包括地址和MessageQueue,那么此时问题的关注点又该从「消息发送给谁」转移到「消息具体发送到哪儿」。 什么叫发送到哪儿? 开篇提到过一个Topic下会被分为很多个MessageQueue,「发送到哪儿」指的就是具体发送到哪一个Message Queue中去。 Message Queue选择机制 核心的选择逻辑 还是先给出流程图 核心逻辑,用大白话讲就是将一个随机数和Message Queue的容量取模。这个随机数存储在Thread Local中,首次计算的时候,会直接随机一个数。 此后,都直接从ThreadLocal中取出该值,并且+1返回,拿到了MessageQueue的数量和随机数两个关键的参数之后,就会执行最终的计算逻辑。 接下来,我们来看看选择Message Queue的方法SelectOneMessageQueue都做了什么操作吧。 可以看到,主逻辑被变量sendLatencyFaultEnable分为了两部分。 容错机制下的选择逻辑 该变量表意为发送延迟故障。本质上是一种容错的策略,在原有的MessageQueue选择基础上,再过滤掉不可用的Broker,对之前失败的Broker,按一定的时间做退避。 可以看到,如果调用Broker信息发生了异常,那么就会调用updateFault这个方法,来更新Broker的Aviable情况。注意这个参数isolation的值为true。接下来我们从源码级别来验证上面说的退避3000ms的事实。 可以看到,isolation值是true,则duration通过三元运算符计算出来结果为30000,也就是30秒。所以我们可以得出结论,如果发送消息抛出了异常,那么直接会将该Broker设置为30秒内不可用。 而如果只是发送延迟较高,则会根据如下的map,根据延迟的具体时间,来判断该设置多少时间的不可用。 例如,如果上次请求的latency超过550ms,就退避3000ms;超过1000,就退避60000; 正常情况下的选择逻辑 而正常情况下,如果当前发送故障延迟没有启用,则会走常规逻辑,同样的会去for循环计算,循环中取到了MessageQueue之后会去判断是否和上次选择的MessageQueue属于同一个Broker,如果是同一个Broker,则会重新选择,直到选择到不属于同一个Broker的MessageQueue,或者直到循环结束。这也是为了将消息均匀的分发存储,防止数据倾斜。 发送消息 选到了具体的Message Queue之后就会开始执行发送消息的逻辑,就会调用底层Netty的接口给发送出去,这块暂时没啥可看的。 Broker的启动流程 主从同步 在上面提到过,RocketMQ有自己的主从同步,但是有两个不同的版本,版本的分水岭是在4.5版本。这两个版本区别是什么呢? 4.5之前:有点类似于Redis中,我们手动的将某台机器通过命令slave of 变成另一台Redis的Slave节点,这样一来就变成了一个较为原始的一主一从的架构。为什么说原始呢?因为如果此时Master节点宕机,我们需要人肉的去做故障转移。RocketMQ的主从架构也是这种情况。 4.5之后:引入了Dleger,可以实现一主多从,并且实现自动的故障转移。这就跟Redis后续推出了Sentinel是一样的。Dleger也是类似的作用。 下图是Broker启动代码中的源码。 可以看到判断了是否开启了Dleger,默认是不开启的。所以就会执行其中的逻辑。 刚好我们就看到了,里面有Rocket主从同步数据的相关代码。 如果当前Broker节点的角色是Slave,则会启动一个周期性的定时任务,定期(也就是10秒)去Master Broker同步全量的数据。同步的数据包括: Topic的相关配置 Cosumer的消费偏移量 延迟消息的Offset 订阅组的相关数据和配置 注册Broker 完成了主动同步定时任务的启动之后,就会去调用registerBrokerAll去注册Broker。可能这里会有点疑问,我这里是Broker启动,只有当前一个Broker实例,那这个All是什么意思呢? All是指所有的NameServer,Broker启动的时候会将自己注册到每一个NameServer上去。为什么不只注册到一个NameServer就完事了呢?这样一来还可以提高效率。归根结底还是高可用的问题。 如果Broker只注册到了一台NameServer上,万一这台NameServer挂了呢?这个Broker对所有客户端就都不可见了。实际上Broker还在正常的运行。 进到registerBrokerAll中去。 可以看到,这里会判断是否需要进行注册。通过上面的截图可以看到,此时forceRegister的值为true,而是否要注册,决定权就交给了needRegister 为什么需要判断是否需要注册呢?因为Broker一旦注册到了NameServer之后,由于Producer不停的在写入数据,Consumer也在不停的消费数据,Broker也可能因为故障导致某些Topic下的Message Queue等关键的路由信息发生变动。 这样一来,NameServer中的数据和Broker中的数据就会不一致。 如何判断是否需要注册 大致的思路是,Broker会从每一个NameServer中获取到当前Broker的数据,并和当前Broker节点中的数据做对比。但凡有一台NameServer数据和当前Broker不一致,都会进行注册操作。 接下来,我们从源码层面验证这个逻辑。关键的逻辑我在图中也标注了出来。 可以看到, 就是通过对比Broker中的数据版本和NameServer中的数据版本来实现的。这个版本,注册的时候会写到注册的数据中存入NameServer中。 这里由于是有多个,所以RocketMQ用线程池来实现了多线程操作,并且用CountDownLatch来等待所有的返回结果。经典的用空间换时间,Golang里面也有类似的操作,那就是sync.waitGroup。 关于任何一个数据不匹配,都会进行重新注册的事实,我们也从源码层面来验证一下。 可以看到,如果任何一台NameServer的数据发生了Change,都会break,返回true。 这里的结果列表使用的是CopyOnWriteList来实现的。 因为这里是多线程去执行的判断逻辑,而正常的列表不是线程安全的。CopyOnWriteArrayList之所以是线程安全的,这归功于COW(Copy On Write),读请求时共用同一个List,涉及到写请求时,会复制出一个List,并在写入数据的时候加入独占锁。比起直接对所有操作加锁,读写锁的形式分离了读、写请求,使其互不影响,只对写请求加锁,降低了加锁的次数、减少了加锁的消耗,提升了整体操作的并发。 执行注册逻辑 这块就是构建数据,然后多线程并发的去发送请求,用CopyOnWriteArrayList来保存结果。不过,上面我们提到过,Broker注册的时候,会把数据版本发送到NameServer并且存储起来,这块我们可以看看发送到NameServer的数据结构。 可以看到,Topic的数据分为了两部分,一部分是核心的逻辑,另一部分是DataVersion,也就是我们刚刚一直提到的数据版本。 Broker如何存储数据 刚刚在聊Producer最后提到的是,发送消息到Broker就完了。不知道大家有没有想过Broker是如何存储消息的? Commit log 先给出流程图 然后给出结论,Producer发送的消息是存储在一种叫commit log的文件中的,Producer端每次写入的消息是不等长的,当该CommitLog文件写入满1G,就会新建另一个新的CommitLog,继续写入。此次采取的是顺序写入。 那么问题来了,Consumer来消费的时候,Broker是如何快速找到对应的消息的呢?我们首先排除遍历文件查找的方法, 因为RocketMQ是以高吞吐、高性能著称的,肯定不可能采取这种对于很慢的操作。那RocketMQ是如何做的呢? 答案是ConsumerQueue ConsumerQueue ConsumerQueue是什么?是文件。引入的目的是什么呢?提高消费的性能。 Broker在收到一条消息的时候,写入Commit Log的同时,还会将当前这条消息在commit log中的offset、消息的size和对应的Tag的Hash写入到consumer queue文件中去。 每个MessageQueue都会有对应的ConsumerQueue文件存储在磁盘上,每个ConsumerQueue文件包含了30W条消息,每条消息的size大小为20字节,包含了8字节CommitLog的Offset、4字节的消息长度、8字节的Tag的哈希值。这样一来,每个ConsumerQueue的文件大小就约为5.72M。 当该ConsumerQueue文件写满了之后,就会再新建一个ConsumerQueue文件,继续写入。 所以,ConsumerQueue文件可以看成是CommitLog文件的索引。 负载均衡 什么意思呢?假设我们总共有6个MessageQueue,然后此时分布在了3台Broker上,每个Broker上包含了两个queue。此时Consumer有3台,我们可以大致的认为每个Consumer负责2个MessageQueue的消费。但是这里有一个原则,那就是一个MessageQueue只能被一台Consumer消费,而一台Consumer可以消费多个MessageQueue。 为什么?道理很简单,RocketMQ支持的顺序消费,是指的分区顺序性,也就是在单个MessageQueue中,消息是具有顺序性的,而如果多台Consumer去消费同一个MessageQueue,就很难去保证顺序消费了。 由于有很多个Consumer在消费多个MessageQueue,所以为了不出现数据倾斜,也为了资源的合理分配利用,在Producer发送消息的时候,需要尽可能的将消息均匀的分发给多个MessageQueue。 同时,上面那种一个Consumer消费了2个MessageQueue的情况,万一这台Consumer挂了呢?这两个MessageQueue不就没人消费了? 以上两种情况分别是Producer端的负载均衡、Consumer端的负载均衡。 Producer端负载均衡 关于Producer端上面的负载均衡,上面的流程图已经给了出来,并且给出了源码的验证。首先是容错策略,会去避开一段时间有问题的Broker,并且加上如果选择了上次的Broker,就会重新进行选择。 Consumer端负载均衡 首先Consumer端的负责均衡可以由两个对象触发: Broker Consumer自身 Consumer也会向所有的Broker发送心跳,将消息的消费组名称、订阅关系集合、消息的通信模式和客户端的ID等等。Broker收到了Consumer的心跳之后,会将其存在Broker维护的一个Manager中,名字叫ConsumerManager。当Broker监听到了Consumer数量发生了变动,就会通知Consumer进行Rebalance。 但是如果Broker通知Consumer进行Rebalance的消息丢了呢?这也就是为什么需要第Consumer自身进行触发的原因。Consumer会在启动的时候启动定时任务,周期性的执行rebalance操作。 默认是20秒执行一次。具体的代码如下。 具体流程 首先,Consumer的Rebalance会获取到本地缓存的Topic的全部数据,然后向Broker发起请求,拉取该Topic和ConsumerGroup下的所有的消费者信息。此处的Broker数据来源就是Consumer之前的心跳发送过去的数据。然后会对Topic中MessageQueue和消费者ID进行排序,然后用消息队列默认分配算法来进行分配,这里的默认分配策略是平均分配。 首先会均匀的按照类似分页的思想,将MessageQueue分配给Consumer,如果分配的不均匀,则会依次的将剩下的MessageQueue按照排序的顺序,从上往下的分配。所以在这里Consumer 1被分配到了4个MessageQueue,而Consumer 2被分配到了3个MessageQueue。 Rebalance完了之后,会将结果和Consumer缓存的数据做对比,移除不在ReBalance结果中的MessageQueue,将原本没有的MessageQueue给新增到缓存中。 触发时机 Consumer启动时 启动之后会立马进行Rebalance Consumer运行中 运行中会监听Broker发送过来的Rebalance消息,以及Consumer自身的定时任务触发的Rebalance Consumer停止运行 停止时没有直接的调用Rebalance,而是会通知Broker自己下线了,然后Broker会通知其余的Consumer进行Rebalance。 换一个角度来分析,其实就是两个方面,一个是队列信息发生了变化,另一种是消费者发生了变化。 源码验证 然后给出核心的代码验证,获取数据的逻辑如下 验证了我们刚刚说的获取了本地的Topic数据缓存,和从Broker端拉取所有的ConsumerID。 接下来是验证刚说的排序逻辑。 接下来是看判断结果是否发生了变化的源码。 可以看到,Consumer通知Broker策略,其本质上就是发送心跳,将更新后的数据通过心跳发送给所有的Broker。 Consumer更多的细节 可能关于Consumer,我们使用的更多一点。例如我们知道我们可以设置集群消费和广播消息,分别对应RocketMQ中的CLUSTERING和BROADCASTING**。 再比如我们知道,我们可以设置顺序消费和并发消费等等,接下来就让我们用源码来看看这些功能在RocketMQ中是怎么实现的。 消费模型 在Consumer中,默认都是采用集群消费,这块在Consumer的代码中也有体现。 而消费模式的不同,会影响到管理offset的具体实现。 可以看到,当消费模型是广播模式时,Offset的持久化管理会使用实现LocalFileOffsetStorage 当消费模式是集群消费时,则会使用RemoteBrokerOffsetStore。 具体原因是什么呢?首先我们得知道广播模式和集群模式的区别在哪儿: 广播模式下,一条消息会被ConsumerGroup中的每一台机器所消费 集群模式下,一条消息只会被ConsumerGroup中的一台机器消费 所以在广播模式下,每个ConsumerGroup的消费进度都不一样,所以需要由Consumer自身来管理Offset。而集群模式下,同个ConsumerGroup下的消费进度其实是一样的,所以可以交由Broker统一管理。 消费模式 消费模式则分为顺序消费和并发消费,分别对应实现MessageListenerOrderly和MessageListenerConcurrently两种方式。 不同的消费方式会采取不同的底层实现,配置完成之后就会调用start。 拉取消息 接下来我们来看一个跟我们最最相关的问题,那就是我们平时消费的消息到底是怎么样从Broker发到的Consumer。在靠近启动Rebalance的地方,Consumer也开启了一个定时拉取消息的线程。 这个线程做了什么事呢?它会不停的从一个维护在内存中的Queue中获取一个在写入的时候就构建好的PullRequest对象,调用具体实现去不停的拉取消息了。 处理消费结果 在这里是否开启AutoCommit,所做的处理差不了很多,大家也都知道,唯一区别就在于是否自动的提交Offset。对于处理成功的逻辑也差不多,我们平时业务逻辑中可能也并不关心消费成功的消息。我们更多关注的是如果消费失败了,RocketMQ是怎么处理的? 这是在AutoCommit下,如果消费失败了的处理逻辑。会记录一个失败的TPS,然后这里有一个非常关键的逻辑,那就是checkReconsumeTimes。 如果当前消息的重试次数,如果大于了最大的重试消费次数,就会把消费发回给Broker。那最大重试次数是如何定义的。 如果值为-1,那么最大次数就是MAX_VALUE,也就是2147483647。这里有点奇怪啊,按照我们平常的认知,难道不是重试16次吗?然后就看到了很骚的一句注释。 -1 means 16 times,这代码确实有点,一言难尽。 然后,如果超过了最大的次数限制,就会将该消息调用Prodcuer的默认实现,将其发送到死信队列中。当然,死信队列也不是什么特殊的存在,就是一个单独的Topic而已。 通过getRetryTopic来获取的,默认是给当前的ConsumerGroup名称加上一个前缀。 好了以上就是本篇博客的全部内容了,如果你觉得这篇文章对你有帮助,还麻烦点个赞,关个注,分个享,留个言。 欢迎微信搜索关注【SH的全栈笔记】,查看更多相关文章

资源下载

更多资源
腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册