开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

基于MySQL binlog日志,实现Elasticsear

发表于 2021-09-13

背景

在我们的开发过程中,经常会在一个项目中使用多种数据库系统。在一些特定场景下,我们希望把数据从一种数据库,同步到另一种异构的数据库,以便进行数据分析统计、完成实时监控、实时搜索等功能。这个异构数据源同步的过程称为Change Data Capture(变化数据捕获)。

图1-变化数据捕获.png

我们本文讨论的是Source为MySQL、Target为ElasticSearch的场景下,进行增量和全量同步操作过程。众所周知,MySQL数据库凭借其性能卓越、服务稳定、开放源代码、社区活跃等因素,成为当下最流行的关系型数据库,但是在数据量级较大或涉及到多表操作时,亦或是需要根据地理位置进行查询时,MySQL通常不能给我们很好的支持。

为了解决MySQL查询缓慢、无法查询的问题,我们通常采用ElasticSearch等进行配合检索。在传统方式中,将MySQL同步数据到ElasticSearch通常采用的是双写、MQ消息等形式,这些形式都存在着耦合高、性能差、丢失数据等风险。

所以我们需要探索出一套对业务无侵入的MySQL同步至ElasticSearch异构数据库的解决方案。本文将分别从增量同步、全量同步两个层面进行探讨。

增量同步

架构

将MySQL数据实时增量同步至ElasticSearch中,一般会借助MySQL增量日志binlog实现。目前比较流行的binlog解析获取中间件是由Alibaba开源的Canal,Canal译意为水道/管道/沟渠,主要用途是基于MySQL数据库增量日志解析,提供增量数据订阅和消费。

所以我们整体的解决方案为:上游通过Canal将解析好的binlog消息发送到Kafka中,下游通过一个Adapter进行解析配置、消费消息。

图2-增量同步流程图.png

参考上面的图片,我们可以知道整体分成了三个步骤:

  1. Canal通过伪装成MySQL Slave,模拟MySQL Slave的交互协议,向MySQL Master发送dump协议,MySQL Master收到Canal发送过来的dump请求,开始推送binlog给Canal,然后Canal解析binlog;
  2. Canal将解析序列化好的binlog信息发送到Kafka;
  3. Adapter根据用户配置信息,接收Kafka中的信息解析处理,将最终数据更新到ElasticSearch的操作。

Adapter设计思路

经过调研,Adapter决定采用通过SQL语句配置,系统根据SQL进行解析获得所需表及字段映射关系形式。解析这一过程利用开源数据库连接池Druid实现。

比如下面的演示,用户配置了一条SQL语句,系统自动解析确定ElasticSearch的字段信息,并缓存MySQL的表和字段与ElasticSearch的字段映射关系。

使用者可以通过定义字段的别名设置在ElasticSearch中的对应字段名,同名字段则不需要别名。

图3-sql配置.gif

Adapter的整体流程可以用下图表示:

图4-整体流程.png

分场景处理

我们已经明确了整体的架构思路,接下来我们需要考虑需要在编写Adapter的过程中,让他具备哪些解析能力。

单表场景

单表同步是最简单的同步场景,当数据库中一张表内容发生变化,将需要的字段提取并写入ElasticSearch中。

举个例子,我们现在有一张student表,我们希望将其中的id、name、age、birthday字段信息信息同步到ElasticSearch,如下图:

图5-单表场景.png

那么需要配置的语句是:

1
2
3
4
5
6
7
8
python复制代码SELECT
    s.id AS _id,
    s.id,
    s.name,
    s.age,
    s.birthday
FROM
    student s;

其中_id表示的是ElasticSearch唯一标识;id是ElasticSearch中实际的字段。

我们需要做的也相对简单,只是将原有数据进行过滤,将需要的数据写入ElasticSearch中即可。

多表简单场景

多表同步是指两张表利用Left Join进行关联的场景,一般是左表的一个字段记录了右表的id信息,将两张表Left Join后即可获取到所有需要的信息。

举个例子,我们希望记录下学生的班级信息,所以在学生表中添加了class_id字段,字段对应class表的id字段;

如下图两张表,我们希望将student表和class表关联,在ElasticSearch中储存其中的id、name、class_id、class_name信息。

图6-多表简单场景.png

那么我们可以配置成下面的形式:

1
2
3
4
5
6
7
8
9
10
vbnet复制代码SELECT
    s.id AS _id,
    s.id,
    s.name,
    c.class_id,
    c.class_name
FROM
    student s
    LEFT JOIN class c
    ON s.class_id = c.id;

首先,我们约定将具有关联性质的字段称为关联字段,比如上面SQL的student表的class_id字段和class表的id字段都是关联字段。

针对这种场景,Adapter会同时监听两张表的更新,分别是:student和class,并针对更改的字段确定需要解决的方式:

  • 左表非关联字段:直接通过binlog中的信息更新到ElasticSearch中;
  • 右表非关联字段:搜索ElasticSearch确定影响范围后,将修改数据写入ElasticSearch中;
  • 关联字段更新:通过在SQL语句后拼接where条件查询MySQL后更新。

多表复杂场景

多行变一行

将多行数据变为一行数据也是多表关联的一种形式,一般会将多条数据使用指定的拼接符号进行连接。

比如:一个同学需要学习的多种课程、一个联系人的多个手机号码等。

举个例子,我们现在还有一张学生家长表:parent,我们期望在ElasticSearch中储存学生的全部家长姓名信息,包括student的id、name、age和parentNames。parentNames是此学生的所有家长姓名,并用逗号分隔。如下图:

图7-多行变一行.png

那么我们可以通过配置下面的语句实现这种效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vbnet复制代码SELECT
    s.id AS _id,
    s.id,
    s.name,
    s.age,
    p.parentNames
FROM
    student s
    LEFT JOIN (
    SELECT
        student_id,
        group_concat( parent_name ORDER BY id DESC SEPARATOR ',' ) AS parentNames
    FROM
        parent
    GROUP BY
    student_id
    ) p
    ON s.id = p.student_id

字段子查询

上面多行变一行的例子还可以通过字段子查询来实现,我们可以配置下面的语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vbnet复制代码SELECT
    s.id AS _id,
    s.id,
    s.name,
    s.age,
    (
    SELECT
        group_concat(
            p.parent_name
        ORDER BY
            p.id DESC SEPARATOR ','
        ) AS parentNames
    FROM
        parent p
    WHERE
        p.student_id = s.id
    GROUP BY
        p.student_id
    ) parentNames
FROM
    student s

Adapter会在解析配置的时候缓存子查询表和外层数据表的关联关系,并在感知到不同数据表变化时做出不同动作:

  • parent表更新:获取关联字段student_id信息,在整个SQL后拼接外层表的字段限定条件,比如s.id = ××。查询后更新;
  • student表更新:获取主表的id字段,在整个SQL后拼接限制条件查询后更新。

当然,Adapter也支持更加复杂的形式,比如在子查询中是Left Join或外层查询是Left Join的场景。

储存到ElasticSearch

从获取binlog到储存至ElasticSearch,我们需要保证一些特性,解决一些问题。

关注特性

顺序性

在单表和多表的部分场景,我们并不会回到MySQL中再次查询最新的数据,直接将binlog中的数据置入ElasticSearch意味着我们必须保证整体的顺序性。

如果我们错误的处理了两条binlog的顺序,我们很有可能导致写入的数据只是更新过程的一个中间版本而不是最终版本。

顺序性包括了binlog本身有序性和Adapter处理的顺序性。

MySQL5.6之前版本通过prepare_commit_mutex锁保证MySQL数据库上层二进制日志和Innodb存储引擎层的事务提交顺序一致。在5.6及之后版本通过引入BLGC(Binary Log Group Commit), 将二进制日志的提交过程分成三个阶段,Flush stage、Sync stage、Commit stage,实现二进制日志和实际提交顺序一致。

而Adapter的有序解析我们可以通过数据库关联分组分topic传递实现。那么什么是具有关联性的数据表呢?举个例子,用户配置了以下三条SQL语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
css复制代码SELECT
    a.id AS _id,
    a.student_name,
    a.student_age,
    a.class_id,
    b.class_name,
    b.teacher_name
FROM
    student a
    LEFT JOIN teacher b
    ON a.teacher_id = b.id;
 
SELECT
    b.id AS _id,
    b.teacher_name,
    b.teacher_age,
    b.campus_id,
    c.campus_address,
    c.campus_name
FROM
    teacher b
    LEFT JOIN campus c
    ON b.campus_id = c.id;
    
SELECT
    d.id AS _id,
    d.class_name,
    d.class_introduce
FROM
    class d;

我们可以看到前两条语句都涉及到了teacher表,另外还分别涉及student表和campus表;第三条语句仅涉及到class表。由于前两条语句同步时都需要依赖teacher的binlog,所以我们将student、teacher、campus设为一组,将class设为一组。同组的binlog数据需要保证有序性。

由于Adapter通过下游的定义很难影响上游Kafka的Partition分配,所以我们推荐的做法是每组都采用单topic单Partition进行传递。当然,Adapter内部也有分组的机制,如果多组混合传递我们也能够完美的进行分组多线程高效解析处理。

可靠性

我们如果期望保证ElasticSearch和MySQL数据库中的数据一致,我们就需要完整的处理每一条binlog,不会出现丢消息的场景。所以,我们需要保证可靠性。

可靠性即保证每一条消息都被消费,不会出现丢消息或因重复消费带来的错误,所以我们需要实现消费幂等性。

考虑到Adapter同步程序可能面临各种正常或因异常情况出现的退出,所以我们利用Kafka的手动Offset机制,在确定一条Message数据被成功写入ElasticSearch后,才Commit该条Message的Offset,这样就保证了不会丢消息。

对于不回表查询直接向ElasticSearch置入数据且类型是Update的场景中,我们会对ElasticSearch中数据初始态进行判断,确认和binlog数据中oldData信息一致时,我们才会进行更新;对于回表场景已经获得最新数据,所以重复消费也不会造成影响。

解决问题

批量提交

为了提高速度,ElasticSearch将采取批量提交的形式提高整体速度,调用添加方法将会自动根据不同ElasticSearch服务集群储存进入不同的BulkRequest对象,在该组全部处理完成后进行提交。

JSON数据

我们必须承认,在MySQL中储存JSON并不是一种优雅的问题解决方案,但是在一些场景下我们依旧会在MySQL中储存JSON字符串。

在我们的Adapter中,我们提供了自动识别JSON并以嵌套文档的形式进行插入的能力;当然,在使用这项功能之前我们必须保证同一个名称的对应内容都是相同类型。

Geo数据

在MySQL中,经纬度通常是两个字段,但是我们在储存到ElasticSearch中期望储存为一个geo_point类型;亦或是MySQL中将一个多边形数据储存为了一个字符串,我们在储存到ElasticSearch中期望储存为一个geo_shape类型。

这时候仅靠简单的同步更新并不能解决,尤其是地理位置多边形等geo_shape场景。我们最终选择通过标记函数的形式进行处理,配置用户通过自定义的geo_shape标记函数示意系统需要对字段进行解析,Adapter解析SQL的时候通过用户自定义的函数获取需要被解析处理的字段。

例如下面这条SQL:

1
css复制代码SELECT a.id as _id, geo_shape(a.geo_shape) FROM geography;

值得一提的是,在ElasticSearch中,经常会因为地理坐标图形构建不合法导致无法存储,所以我们在更新地理类的数据类型之前都会利用spatial4j提前进行判断,如果存在问题直接上报监控中心实现自动感知和通知。

内容过滤

我们的Adapter更希望能够根据用户的定义,灵活选择转储到ElasticSearch的数据。其中不乏很常见的使用场景逻辑删除,但是目前开源的Adapter对于where条件的支持通常都存在问题。

我们结合已有开源Adapter的issue分析了一下导致where删除逻辑失效的原因:①单表更新不回表查询,所以没有机会拼接where查询 ②回表查询到符合条件的更新进ElasticSearch,但是ElasticSearch原有符合但现在不符合数据不能及时删除。

为了解决这两个问题且提高效率,我们选定方案为允许用户配置MVEL表达式,在提交前解析表达式对即将提交数据进行校验,符合则提交,如果不符合就根据 _id 将ElasticSearch中对应数据进行删除,并停止向ElasticSearch更新本条数据信息。

小结

目前我们已经支持了以上功能,从线上的监控来看,单条消息平均处理时长在30ms左右;P99在80ms左右,基本符合线上实际业务需求。

全量同步

概述

全量同步相比于增量同步简单了许多,全量同步的主要功能是将MySQL中的数据优雅且完整的置入到ElasticSearch中。

这看起来也十分容易,通过SQL查询的形式,将查询结果写到ElasticSearch中就可以,在这个过程中我们需要提前考虑哪些问题呢?

关注点

深分页和丢数据

深分页会造成较大负担比较容易理解,深分页可能还会带来漏处理的问题。 比如在id较小的部分删除一条数据,深分页可能导致分页接缝出现变化,当前处理页和下一页可能出现丢失数据。

比如下图中的例子,我们深分页每次获取5条数据,由于第一次获取之后用户删除了第4条信息为 “5” 的数据,导致下一次分页直接从“9”开始,造成信息为 “8” 的数据被漏处理。我们的解决办法就是采用根据id范围进行更新。

图8-深分页数据丢失.png

全量增量互相影响

我们来举一个场景的例子来更好的理解这种影响:

  1. 程序读取 id 为 1 ~ 1000 的数据到内存准备全量更新
  2. MySQL修改 id = 100 的数据其中 name 由“张三”改为“李四”
  3. 程序正常进行增量更新,校验ElasticSearch中_id为100的数据name为“张三”,将其更新为“李四”
  4. 程序全量处理完毕,将1 ~ 1000 条数据写入ElasticSearch,其中_id为100的数据name被写为“张三”
  5. 全量继续进行,直至结束

在此过程中,数据库中id为100的数据name是“李四”,但是由于全量更新和增量更新彼此影响导致ElasticSearch的数据被误写为“张三”。

我们可以通过分段锁的形式细化锁的粒度,实现全量同步同时进行增量同步。

结语

异构数据库的同步一直以来都是一个相对复杂的问题,希望能够对通过本文帮助大家对于异构数据库同步有更多了解,同时为大家提供更多的思路和帮助。

作者

齐同学,便利蜂营建和设备技术团队的2021届校园招聘应届生。

最后,便利蜂正在寻找优秀的伙伴,每一份简历我们都会认真对待,期待遇见。

  • 邮箱地址:tech-hiring@bianlifeng.com
  • 邮件标题:营建和设备技术团队

招聘官网

bianlifeng.gllue.me/portal/home…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

完蛋,公司被一条 update 语句干趴了!

发表于 2021-09-13

大家好,我是小林。

昨晚在群划水的时候,看到有位读者说了这么一件事。

在这里插入图片描述

大概就是,在线上执行一条 update 语句修改数据库数据的时候,where 条件没有带上索引,导致业务直接崩了,被老板教训了一波

这次我们就来看看:

  • 为什么会发生这种的事故?
  • 又该如何避免这种事故的发生?

说个前提,接下来说的案例都是基于 InnoDB 存储引擎,且事务的隔离级别是可重复读。

为什么会发生这种的事故?

InnoDB 存储引擎的默认事务隔离级别是「可重复读」,但是在这个隔离级别下,在多个事务并发的时候,会出现幻读的问题,所谓的幻读是指在同一事务下,连续执行两次同样的查询语句,第二次的查询语句可能会返回之前不存在的行。

因此 InnoDB 存储引擎自己实现了行锁,通过 next-key 锁(记录锁和间隙锁的组合)来锁住记录本身和记录之间的“间隙”,防止其他事务在这个记录之间插入新的记录,从而避免了幻读现象。

当我们执行 update 语句时,实际上是会对记录加独占锁(X 锁)的,如果其他事务对持有独占锁的记录进行修改时是会被阻塞的。另外,这个锁并不是执行完 update 语句就会释放的,而是会等事务结束时才会释放。

在 InnoDB 事务中,对记录加锁带基本单位是 next-key 锁,但是会因为一些条件会退化成间隙锁,或者记录锁。加锁的位置准确的说,锁是加在索引上的而非行上。

比如,在 update 语句的 where 条件使用了唯一索引,那么 next-key 锁会退化成记录锁,也就是只会给一行记录加锁。

这里举个例子,这里有一张数据库表,其中 id 为主键索引。

假设有两个事务的执行顺序如下:

在这里插入图片描述

可以看到,事务 A 的 update 语句中 where 是等值查询,并且 id 是唯一索引,所以只会对 id = 1 这条记录加锁,因此,事务 B 的更新操作并不会阻塞。

但是,在 update 语句的 where 条件没有使用索引,就会全表扫描,于是就会对所有记录加上 next-key 锁(记录锁 + 间隙锁),相当于把整个表锁住了。

假设有两个事务的执行顺序如下:

可以看到,这次事务 B 的 update 语句被阻塞了。

这是因为事务 A的 update 语句中 where 条件没有索引列,所有记录都会被加锁,也就是这条 update 语句产生了 4 个记录锁和 5 个间隙锁,相当于锁住了全表。

因此,当在数据量非常大的数据库表执行 update 语句时,如果没有使用索引,就会给全表的加上 next-key 锁, 那么锁就会持续很长一段时间,直到事务结束,而这期间除了 select ... from 语句,其他语句都会被锁住不能执行,业务会因此停滞,接下来等着你的,就是老板的挨骂。

那 update 语句的 where 带上索引就能避免全表记录加锁了吗?

并不是。

关键还得看这条语句在执行过程种,优化器最终选择的是索引扫描,还是全表扫描,如果走了全表扫描,就会对全表的记录加锁了。

又该如何避免这种事故的发生?

我们可以将 MySQL 里的 sql_safe_updates 参数设置为 1,开启安全更新模式。

官方的解释:
If set to 1, MySQL aborts UPDATE or DELETE statements that do not use a key in the WHERE clause or a LIMIT clause. (Specifically, UPDATE statements must have a WHERE clause that uses a key or a LIMIT clause, or both. DELETE statements must have both.) This makes it possible to catch UPDATE or DELETE statements where keys are not used properly and that would probably change or delete a large number of rows. The default value is 0.

大致的意思是,当 sql_safe_updates 设置为 1 时。

update 语句必须满足如下条件之一才能执行成功:

  • 使用 where,并且 where 条件中必须有索引列;
  • 使用 limit;
  • 同时使用 where 和 limit,此时 where 条件中可以没有索引列;

delete 语句必须满足如下条件之一才能执行成功:

  • 使用 where,并且 where 条件中必须有索引列;
  • 同时使用 where 和 limit,此时 where 条件中可以没有索引列;

如果 where 条件带上了索引列,但是优化器最终扫描选择的是全表,而不是索引的话,我们可以使用 force index([index_name]) 可以告诉优化器使用哪个索引,以此避免有几率锁全表带来的隐患。

总结

不要小看一条 update 语句,在生产机上使用不当可能会导致业务停滞,甚至崩溃。

当我们要执行 update 语句的时候,确保 where 条件中带上了索引列,并且在测试机确认该语句是否走的是索引扫描,防止因为扫描全表,而对表中的所有记录加上锁。

我们可以打开 MySQL sql_safe_updates 参数,这样可以预防 update 操作时 where 条件没有带上索引列。

如果发现即使在 where 条件中带上了列索引列,优化器走的还是全标扫描,这时我们就要使用 force index([index_name]) 可以告诉优化器使用哪个索引。

这次就说到这啦,下次要小心点,别再被老板挨骂啦。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Dubbo 和 HSF 在阿里巴巴的实践:携手走向下一代云原

发表于 2021-09-13

简介: HSF 和 Dubbo 的融合是大势所趋。为了能更好的服务内外用户,也为了两个框架更好发展,Dubbo 3.0 和以 Dubbo 3.0 为内核适配集团内基础架构生态的 HSF 3 应运而生。

作者 | 郭浩

Dubbo 和 HSF 都是阿里巴巴目前在使用的微服务 RPC 框架。HSF 在阿里巴巴使用更多,承接了内部从单体应用到微服务的架构演进,支撑了阿里历年双十一的平稳运行;Dubbo 则在 2011 年开源后,迅速成为业界广受欢迎的微服务框架产品,在国内外均有着广泛应用。

自 2008 年 5 月发布第一个版本 1.1 后,经历数年迭代,HSF 从一个基础的 RPC 框架逐渐演变成为日支撑十万亿级别调用的易于扩展的微服务框架。内部场景中,用户既可以选择少量配置轻松接入微服务体系,获取高性能的稳定服务调用。也可以按照自身业务需求,对 HSF 进行扩展,获取整条链路的能力增强。

Dubbo 项目诞生于 2008 年,起初只在一个阿里内部的系统使用;2011 年,阿里 B2B 决定将整个项目开源,仅用了一年时间就收获了来自不同行业的大批用户;2014 年,由于内部团队调整,Dubbo 暂停更新;2017 年 9 月,Dubbo 3 重启开源,在 2019 年 5 月由 Apache 孵化毕业,成为第二个由阿里巴巴捐献至 Apache 毕业的项目。

Dubbo 和 HSF 在阿里巴巴的实践

2008 年的时候,集团内部淘系主要使用的服务框架是 HSF, 而 B2B 使用的则是 Dubbo。二者独立,各行其道,彼此不通。随着业务飞速发展,跨语言、跨平台、跨框架的需求日益明显,不同业务间彼此互联互通的呼声越来越高,而且很快演变成为几乎整个集团的需求。即淘系可以调用 B2B 的服务,反之亦然。

服务框架就像铁路的铁轨一样,是互通的基础,只有解决了服务框架的互通,才有可能完成更高层的业务互通,所以用相同的标准统一,共建新一代的服务框架是必然趋势。也就是最终的框架需要同时兼容 HSF1.x 和 Dubbo (包括 1.x 和 2.x) 的协议。对于集团内的需求而言,稳定和性能是核心,因此,当时选型了在电商这种高并发场景久经考验的 HSF 做为新一代服务框架核心。随后,HSF 推出了 2.0 的版本,并针对 HSF 之前版本的主要问题进行重构改造,降低了维护成本,进一步提高了稳定性和性能。HSF 2.0 解决了通讯协议支持不透明,序列化协议支持不透明等框架扩展性问题。基于 HSF 2.0 的 Java 版本,集团内也演进出了 CPP/NodeJs/PHP 等多语言的客户端。

由于兼容了 Dubbo 的协议,原有的 Dubbo 用户可以平滑地迁移到新版本上,所以 HSF 推出后很快就在集团全面铺开,部署的 server 数量达到数十万,基本完成了阿里巴巴内部微服务框架的统一,并经历了多年双十一零点流量洪峰的验证。

下一代微服务的挑战和机遇

然而,业务的发展和框架自身的迭代使得两个框架从协议层的简单兼容已经无法满足需要。随着云计算的不断发展和云原生理念的广泛传播,微服务的发展有着以下趋势:

1、K8s 成为资源调度的事实标准,Service Mesh 从提出到发展至今已经逐渐被越来越多用户所接受。屏蔽底层基础设施成为软件架构的一个核心演进目标,无论是阿里巴巴还是其他企业用户,所面临的问题都已经从是否上云变为如何平滑稳定地低成本迁移上云。

2、由于上云路径的多样以及由现有架构迁移至云原生架构的过渡态存在,部署应用的设施灵活异变,云上的微服务也呈现出多元化的趋势。跨语言、跨厂商、跨环境的调用必然会催生基于开放标准的统一协议和框架,以满足互通需求。

3、端上对后台服务的访问呈爆炸性的趋势增长,应用的规模和整个微服务体系的规模都随之增长。

这些趋势也给 HSF 和 Dubbo 带来了新的挑战。

第一,上云对内部闭源组件带来了冲击。微服务框架是基础组件,大部分公司在早期选型或业务发展到一定规模的时候都需要确定使用某一个框架。而一个稳定高效的自研框架通常需要较长时间的迭代来打磨优化。所以,大部分公司初期都会倾向于使用开源组件。对阿里云而言,这就带来了一个问题:内部使用的是 HSF 框架,而云上的用户大部分都是使用的开源 Dubbo 框架,两种框架在协议、内部模块抽象、编程接口和功能支持上都存在差异。如何能让使用了 HSF 的阿里集团内部组件的最优实践和前沿技术更简单直接地输出到云上,这是每一个做技术商业化的同学都会遇到和必须解决的问题。

第二,原有部门或公司的技术栈如何更快地融入到现有技术体系是一个绕不开的问题。一个典型的例子就是 2019 年加入阿里巴巴的考拉。考拉之前一直使用 Dubbo 作为微服务框架,基于 Dubbo 构建了大规模的微服务应用,迁移的成本高,风险也大。需要集团和考拉的基础架构部门耗费较长的时间进行迁移前调研、方案设计,确保基本可行后再开始改动。从分批灰度上线,再到最终全量上线。这种换血式的改动不仅需要耗费大量人力,时间跨度也很长,会影响到业务的发展和稳定性。

第三,由于历史原因,集团内部始终存在着一定数量的 Dubbo 用户。为了更好的服务这部分用户,HSF 框架对 Dubbo 进行了协议层和 API 层的兼容。但这种兼容仅限于互通,随着 Dubbo 开源社区的多年发展,这种基础的兼容在容灾、性能和可迭代性方面,都有着较大的劣势,同时很难对齐 Dubbo 的服务治理体系。在稳定性方面也存在风险,更无法享受到集团技术发展和 Dubbo 社区演进的技术红利。

产生这些问题的根本原因是闭源的 HSF 无法直接用于广大云上用户和外部其他用户,而开源产品对闭源产品的挑战会随着开源和云的不断发展愈演愈烈。越早解决这个问题,阿里巴巴和外部企业用户的云原生迁移成本越低,产生的价值也就越大。

最简单直接的方式是将 HSF 也开源出去。但这又会面临两个新问题。第一, Dubbo 是阿里较早开源的明星产品,如果 HSF 也开源,这两个同类框架的关系和适用场景如何划分,不仅外部用户会有疑惑,重复开源也不利于品牌建设。第二,国内外现有的 Dubbo 用户如果想上阿里云,则需要使用基于 HSF 的现有解决方案,需要花费巨大精力将所有用到 Dubbo 的应用迁移到 HSF,成本和稳定性都是不得不考虑的问题 。以上两点原因说明目前已经不是开源 HSF 的最好时机。

既然 HSF 不能走出去,那剩下的解决方式就是让 Dubbo 走进来。内部采用核心融合的方式,基于 Dubbo 内核重新构建 HSF 框架。

品牌建设上,融合可以使 Dubbo 现有的广泛影响力得以持续发展,Dubbo 在集团内大规模落地后,会产生良好的原厂品牌示范效应,外部用户也会有更多的信心在进行微服务框架选型时选择 Dubbo。同时,目前已经使用 Dubbo 的用户也有更充分的理由不断追随版本演进,享受阿里巴巴开源带来的技术红利。

工程实践上,使用 Dubbo 重构 HSF 这种从内部重新统一的可行性更高,迁移的复杂度可控,能够逐步地有序实现。内部完善的测试流程和丰富的场景是对 Dubbo 最好的功能回归测试。内外统一也是兼顾商业化和内部支持的最好方式。在重构的过程中,不断完善功能,提高性能,拥抱更新的更云原生的技术栈,这也是提升集团内部用户体验的最佳方式。

因此,HSF 和 Dubbo 的融合是大势所趋。为了能更好的服务内外用户,也为了两个框架更好发展,Dubbo 3.0 和以 Dubbo 3.0 为内核适配集团内基础架构生态的 HSF 3 应运而生。

下一代云原生微服务

首先总体上介绍一下 Dubbo 3.0 。

  • Dubbo 3.0 支持全新的服务发现模型,Dubbo 3.0 尝试从应用模型入手,优化存储结构,对齐云原生主流设计模型,避免在模型上带来的互通问题。新模型在数据组织上高度压缩,能有效提高性能和集群的可伸缩性。
  • Dubbo 3.0 提出了下一代 RPC 协议 —— Triple。这是一个基于 HTTP/2 设计的完全兼容 gRPC 协议的开放性新协议,由于是基于 HTTP/2 设计的,具有极高的网关友好型和穿透性;完全兼容 gRPC 协议是的天然在多语言互通方面上具有优势。
  • Dubbo 3.0 面向云原生流量治理,提出了一套能够覆盖传统 SDK 部署、Service Mesh 化部署、VM 虚拟机部署、Container 容器部署等场景的统一治理规则,支持一份规则治理大部分场景,大大降低流量治理治理成本,使得异构体系下全局流量治理变得可能。
  • Dubbo 3.0 将提供接入 Service Mesh 的解决方案,面向 Mesh 场景,Dubbo 3.0 提出了两种接入方式,一种是 Thin SDK 模式,部署模型和当前 Service Mesh 主流部署场景完全一样,而 Dubbo 将进行瘦身,屏蔽掉与 Mesh 相同的治理功能,仅保留核心的 RPC 能力;第二种是 Proxyless 模式,Dubbo 将接替 Sidecar 的工作职责,主动与控制面进行通信,基于 Dubbo 3.0 的统一治理规则应用云原生流量治理功能。

1、应用级注册发现模型

应用级注册发现模型的原型最早在 Dubbo 2.7.6 版本提出,经过数个版本的迭代最终形成了 Dubbo 3.0 中的稳定模型。在 Dubbo 2.7 及以前版本中,应用进行服务注册和发现时,都是以接口为粒度,每个接口都会对应在注册中心上的一条数据,不同的机器会注册上属于当前机器的元数据信息或者接口级别的配置信息,如序列化、机房,单元、超时配置等。所有提供此服务的服务端在进行重启或者发布时,都会在接口粒度独立的变更。

举个例子,一个网关类应用依赖了上游应用的 30 个接口,当上游应用在发布时,就有 30 个对应的地址列表在进行机器上线和下线。以接口作为注册发现第一公民的模型是最早的 SOA 或微服务的拆分方式,提供了灵活的根据单一服务单一节点独立动态变更的能力。随着业务的发展,单一应用依赖的服务数在不断增多,每个服务提供方的机器数量也由于业务或容量原因不断增长。客户端依赖的总服务地址数上涨迅速,注册中心等相关依赖组件的压力倍增。

我们注意到了微服务模型发展的两个趋势:首先,随着单体应用拆分为多微服务应用的基本完成,大规模的服务拆分和重组已经不再是痛点,大部分接口都只被一个应用提供或者固定几个应用提供。其次,大量的用于标志地址信息的 URL 都是存在极大冗余的,如超时时间,序列化,这些配置变更频率极低,却在每个 URL 中都出现。所以应用级注册发现应运而生。

应用级服务发现以应用作为注册发现的基本维度。和接口级的主要区别是,一个应用提供了 100 个接口,按照接口级粒度需要在注册中心上注册 100 个节点,如果这个应用有 100 台机器,那每次发布,对于它的客户端都是 10000 个虚拟节点的变更。而应用级注册发现则只需要 1 个节点, 每次发布只变更 100 个虚拟节点。对于依赖服务数多、机器多的应用而言,是几十到上百分之一数量级的规模下降。内存占用上也会至少下降一半。

最后,由于这个新的服务发现与 Spring Cloud、Service Mesh 等体系下的服务发现模型是一致的,因此 Dubbo 可以从注册中心层面与其他体系下的节点实现互发现,实现异构微服务体系的互联互通。

2、下一代 RPC 协议——Triple

作为 RPC 框架最基础的能力还是完成跨业务进程的服务调用,将服务组成链、组成网,这其中最核心的载体就是协议。Dubbo 2.0 提供了 RPC 的核心语义,包括协议头、标志位、请求 ID 以及请求 / 响应数据,他们被按照一定的顺序以二进制数据的方式组合在一起。

在云原生时代,Dubbo 2.0 协议主要面临两个挑战。一是生态不互通,用户很难直接理解二进制的协议。第二是对 Mesh 等网关型组件不够友好,需要完整的解析协议才能获取到所需要的调用元数据,如一些 RPC 上下文,从性能到易用性方面都会面临挑战。同时,老版本 Dubbo 2.0 RPC 协议的设计与实现,已在实践中被证实在⼀些⽅面限制了业务架构的发展,⽐如从终端设备到后端服务的交互、微服务架构中多语言的采用、服务间的数据传输模型等。那么,在支持已有的功能和解决存在的问题的前提下,下一代的协议需要有哪些特性呢?

首先,新协议应该易扩展,包括但不限于 Tracing/ Monitoring 等支持,也应该能被各层设备识别,降低用户理解难度。

其次,协议需要解决跨语言互通的问题,传统的多语言多 SDK 模式和 Mesh 化跨语言模式都需要一种更通用易扩展的数据传输格式。

最后,协议应该提供更完善的请求模型,除了 Request/Response 模型,还应该支持 Streaming 和 Bidirectional 等模型。

基于这些需求,HTTP2/protobuf 的组合是最符合的。提到这两个,大家可能很容易想到 gRPC 协议。那新一代的协议和 gRPC 的关系是什么呢。

首先,Dubbo 新协议是基于 GRPC 扩展的协议,这也保证了在生态系统上新协议和 GRPC 是能够互通和共享的。其次,在这个基础上,Dubbo 新协议将更原生的支持 Dubbo 的服务治理,提供更大的灵活性。在序列化方面,由于目前大多数应用方还没有使用 Protobuf ,所以新协议会在序列化方面给予足够的支持,平滑的适配现有序列化,方便迁移到 Protobuf。在请求模型上,新协议将原生支持端到端的全链路 Reactive,这也是 gRPC 协议所不具备的。

3、原生接入 Service Mesh

如何让 Dubbo 在 Service Mesh 体系下落地,社区开发团队调研众多方案,最终确定了最适合 Dubbo 3.0 的两种形态的 Mesh 方案。

⼀种是经典的基于 Sidecar 的 Service Mesh,另⼀种是无 Sidecar 的 Proxyless Mesh。对于 Sidecar Mesh 方案,其部署方式和当前主流 Service Mesh 部署方案一致,Dubbo 3.0 的重点是尽量给业务应用提供完全透明的升级体验,这不止是编程视角的无感升级,还包括通过 Dubbo 3.0 轻量化、Triple 协议等,让整个调用链路上的损耗与运维成本也降低到最低。这个方案也被称为 Thin SDK 方案,而 Thin 的地方就是在去除所有不需要的组件。Proxyless Mesh 部署方案则是 Dubbo 3.0 规划的另⼀种 Mesh 形态,目标是不需要启动 Sidecar,由传统 SDK 直接与控制面交互。

我们设想这对以下⼏种场景会⾮常适用 Proxyless Mesh 部署方案:

一是业务方期望升级 Mesh 方案,但却无法接受由于 Sidecar 进行流量劫持所带来的性能损耗,这种情况常见于核心业务场景;

二是期望降低由于部署 Sidecar 带来的运维成本,降低系统复杂度;三是遗留系统升级缓慢,迁移过程漫长,多种部署架构⻓期共存。

最后是,多种部署环境,这里的多种部署环境包括了如 VM 虚拟机、Container 容器等多种部署方式,也包括了多种类型应用混合部署,例如 Thin SDK 与 Proxyless 方案混合部署,对性能敏感应用部署 Proxyless 模式,对于周边应用采用 Thin SDK 部署方案,多种数据面共同由统一控制面进行调度。

将这两种形态统筹来看,在不同的业务场景、不同的迁移阶段、不同的基础设施保障情况下, Dubbo 都会有 Mesh ⽅案可供选择。

4、柔性服务增强

云原生带来了技术标准化的重大变革,如何让应用在云上更简单地创建和运行,并具备可弹性扩展的能力,是所有云原生基础组件的核心目标。借助云原生技术带来的弹性能力,应用可以在极短时间内扩容出一大批机器以支撑业务需要。比如为了应对零点秒杀场景或者突发事件,应用本身往往需要数千甚至数万的节点数来以满足用户的需要,但是在扩容的同时也带来了许多在云原生场景下集群大规模部署的问题。比如由于集群节点极多导致的节点异常频发、服务容量受多种客观因素影响导致节点服务能力不均等。

Dubbo 期待基于一种柔性的集群调度机制来解决这些问题。这种机制主要解决的问题有两个方面:一是在节点异常的情况下,分布式服务能够保持稳定,不出现雪崩等问题;二是对于大规模的应用,能够以最佳态运行,提供较高的吞吐量和性能。从单一服务视角看,Dubbo 期望的目标是对外提供一种压不垮的服务,即是在请求数特别高的情况下,可以通过选择性地拒绝一些的请求来保证总体业务的正确性、时效性。

从分布式视角看,要尽可能降低因为复杂的拓扑、不同节点性能不一导致总体性能的下降,柔性调度机制能够以最优的方式动态分配流量,使异构系统能够根据运行时的准确服务容量合理分配请求,从而达到性能最优。

5、业务收益

对业务而言,可能更关心的是升级到 Dubbo 3.0 能带来哪些收益。总结提炼出两大关键词,分别是应用自身的性能稳定性的提升以及云原生的原生接入。

  • 性能与稳定性方面,Dubbo 3.0 会着力关注大规模集群部署的场景,通过优化数据存储方式,来降低单机资源损耗,同时可以在应对超大规模集群的水平扩容的时候,保证整个集群的稳定性。另外,在 Dubbo 3.0 提出了柔性服务的概念,也能够在一定程度上有效保证和提高全链路总体可靠性和资源的利用率。
  • 第二是关于云原生方面,Dubbo 3.0 是 Dubbo 全面拥抱云原生的里程碑版本,当前 Dubbo 在国内外有基数巨大的用户群体,随着云原生时代的到来,这些用户上云的需求越来越强烈,Dubbo 3.0 将提供完整可靠的解决方案、迁移路径与最佳实践帮助企业实现云原生转型,享受云原生带来的红利。

从已经落地的结果上看,Dubbo 3.0 能⼤幅降低框架带来的额外资源消耗,提升系统资源利用率。从单机视⻆,Dubbo 3.0 能节省约 50% 的内存占⽤;从集群视角,Dubbo3 能⽀持百万实例数的大规模集群,为未来更大规模的业务扩容打下基础;Dubbo3 对 Reactive Stream 等通信模型的支持,在大文件传输、流式等业务场景下能带来整体吞吐量的⼤幅提升。

架构方面,Dubbo 3.0 给业务架构升级带来了更多可能性。Dubbo 原来的协议在⼀定程度上束缚了微服务接⼊⽅式的,举个例子,移动端、前端业务要接入 Dubbo 后端服务,需要经过网关层的协议转换,再比如,Dubbo 只⽀持 request-response 模式的通信,这使得⼀些需要流式传输或反向通信的场景⽆法得到很好的支持。

在云原生转型过程中,业务最关心的就是改动和稳定性,能不能不改代码或者少改代码就能升级到云原生环境,在业务上云过程的选型中至关重要。Dubbo 3.0 给业务侧云原⽣生升级带来了整体的解决方案。不论是底层基础设施升级带动业务升级,还是为解决业务痛点而进行的主动升级,Dubbo 3.0 所提供的云原生解决方案都能帮助产品快速升级,进入云原生时代。

6、现状和 Roadmap

内部使用上,Dubbo 3.0 已经在考拉业务的数百应用的数万节点中全面落地,大量应用使用 Dubbo 3.0 轻松完成应用上云,目前正在电商核心应用中大规模试点和逐步落地,以及开启应用级注册发现、Triple 协议等新特性。开源用户和商业化应用目前也在从原有的 HSF2 或 Dubbo 2.0 迁移至 Dubbo 3.0 ,服务框架团队和社区正在整理和编写相关迁移的最佳实践,一段时间后这些文档就会和大家见面。

Dubbo 3.0 作为捐给 Apache 后的一个里程碑版本已经在今年 6 月份正式发布了,这也代表着 Apache Dubbo 全面拥抱云原生的一个节点。在 2021 年 11 月我们会发布 Dubbo 3.1 版本,届时会带来 Dubbo 在 Mesh 场景下部署的最佳实践。

2022 年 3 月会发布 Dubbo 3.2 版本,这个版本将带来服务柔性的全面支持,在大规模应用部署下实现智能流量调度,提高系统稳定性与资源利用率。

回顾过去,Dubbo 和 HSF 在阿里巴巴和微服务框架的发展的不同阶段都起到了至关重要的作用。立足现在,放眼未来,Dubbo 3.0 和基于 Dubbo 3.0 内核的 HSF 正在外部和内部齐头并进,做最稳定高性能的微服务框架,给用户最好的使用体验,继续在云原生时代引领微服务的发展。

作者介绍:郭浩,阿里巴巴服务框架负责人,Dubbo 3.0 架构师,专注分布式系统架构

原文链接

本文为阿里云原创内容,未经允许不得转载。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

火山引擎 A/B 测试的思考与实践

发表于 2021-09-13

本文整理自火山引擎开发者社区 Meetup 第四期同名演讲,主要为大家介绍了为什么要做 A/B 测试、火山引擎 A/B 测试系统架构及最佳实践。

为什么要做 A/B 测试

首先我们看一个案例。字节跳动有一款中视频产品叫西瓜视频,最早它叫做头条视频。为了提升产品的品牌辨识度,团队想给它起个更好的名字。经过一些内部调研和头脑风暴,征集到了西瓜视频、奇妙视频、筷子视频、阳光视频 4 个名字,于是团队就针对一共 5 个 APP 名称进行了 A/B 实验。这个实验中唯一改变的是应用市场里该产品的名称和对应的 logo,实验目的是为了验证哪一个应用名称能更好地提升“头条视频” APP 在应用商店的点击率。最后西瓜视频和奇妙视频的点击率位列前二,但差距不显著,结合用户调性等因素的综合考量后,最终决定头条视频正式更名为西瓜视频。

通过这个案例可以看到,A/B 测试可以帮助业务做最终决策。结合案例的直观感受,我们可以这样来定义 A/B 测试:在同一时间对目标受众做科学抽样、分组测试以评估效果。

以上图图示为例,假设我们有 100 万用户要进行 A/B 测试:

  • 先选定目标受众,比如一线城市的用户。
  • A/B 测试不可能对所有用户都进行实验,所以要进行科学抽样,选择小部分流量进行实验。
  • 抽样之后需要对样本进行分组,比如 A 组保持现状,B 组的某一个因素有所改变。
  • 分组之后在同一时间进行实验,就可以看到改变变量后用户行为的变化。
  • 再根据对应实验目标的指标,比如点击率的高低,来评估实验的结果。

以上就是我们对 A/B 测试的定义。目前,A/B 测试已被 Google、Facebook、亚马逊等大型互联网公司广泛采用;字节跳动更是在 2012 年成立之初便开始使用 A/B 测试,公司内部一直流传一句话:一切皆可 A/B 测试。A/B 测试在字节跳动已是非常基础的设施和文化,目前,字节跳动累计已有 80W+ 的 A/B 实验,日新增实验 1500+,同时运行试验 1W+,服务 500+ 业务线。

那我们为什么要做 A/B 测试呢?我总结有 3 点原因:

  • 风险控制:小流量实验可以避免直接上线效果不好造成损失。其次,实验迭代的过程中,决策都是有科学依据的,可以避免系统性的偏差。
  • 因果推断:我们相信 A/B 实验中的优化和改变最终能影响到线上数据以及用户的行为。在这个前提下,A/B 测试就是最好的因果推断工具。
  • 复利效应:A/B 测试是可以持续不断进行的实验,即使一次实验提升的效果不大,但是长期下来复利效应的积累会产生很大的变化和回报。

A/B 测试系统实现

了解了我们为什么要做 A/B 测试,下面我们来看一下火山引擎的 A/B 测试系统是如何实现的。

上图是火山引擎 A/B 测试系统的架构示意图,整体架构分为几层:

  • 运行环境层:在最底层,服务可以运行在容器内,也可以运行在物理机上。
  • 基础设施层:会用到关系型数据库和键值对。因为 A/B 测试要处理很大的数据量,这一层也会使用离线和实时的大数据组件。
  • 服务层:包括实验所需的分流服务、元信息服务、调度服务等。在 A/B 测试中我们也需要标识用户,因此这一层有设备服务。为了提供多种数据查询,还有 OLAP 引擎。
  • 业务层:包括实验管理、指标管理、Feature 管理、评估报告等。
  • 接入层:包括 CDN、网络防火墙、负载均衡。
  • 应用层:提供管理后台控制实验、查看报告等,SDK 调用。

下面介绍几个实验流程的实现。

客户端实验参数传递及生效过程

客户端实验的流程如上图所示:

  • 业务方开发策略,确定实验内容;
  • 枚举策略中的映射关系并在客户端实现映射关系;
  • 创建并开启实验;
  • 客户端已经集成了火山引擎 A/B 测试系统的 SDK,向 A/B 测试系统请求分流服务,判断用户命中哪些实验哪些版本,下发参数;
  • 客户端从 SDK 取到参数,进行相对应的流程完成实验。

服务端实验参数传递及生效过程

服务端的实验和客户端类似:

  • 设计实验;
  • 服务端实验的 SDK 是跟业务系统比如服务端集成在一起。客户是从其他 C 端用户直接请求业务的服务端,该服务端会在本地 SDK 做决策;
  • 决策完之后将参数下发到下游,使策略生效。

统计分析实践

在统计分析中,我们总结了一些有用的实践经验:

  • 确定业务的指标体系:可以从宏观/微观、长期/短期、横向/纵向三个角度建设指标体系。
  • 分类检验:对指标进行置信度计算的时候,并不会每次都用同一套方法,而是针对不同的指标类型(包括转化类、人均类、CTR 类等)进行不同的建模采用不同的方法。
  • 统计修正:如果一个实验开了多个组,可能犯了多重比较的错误。还有时开完实验之后每天都会查看结果,这就犯了连续观测的错误。所以在实践中需要有一些统计修正的方法来修正行为。
  • 基于叶贝斯体系的探索:区别于经典的假设检验,我们也在探索基于叶贝斯体系,如何评估实验效果,降低面向用户使用时候的理解门槛。在智能流量调优、模型超参数搜索等场景下有具体落地。

这里也跟大家分享一些 A/B 实验设计背后的思考:

  • 避免过度曝光:A/B 实验中有一个很关键的点是决策哪些样本应该进入实验。如果所有打开应用的人都能命中实验,实验结果就不会很明显。
  • 进组和出组:假设我们对北京的用户进行了实验,有些人出差或者旅游离开北京之后还能命中实验吗?我们可以把这个决策留给实验者,让实验者自己决定是进组还是出组。
  • 和 Feature Flag 的珠联璧合:实验之前可以把能进行实验的内容抽象成 Feature Flag,简单理解成功能开关。实验完成之后的上线或者重复实验,也可用 Feature Flag 进行管理。

字节跳动 A/B 测试最佳实践

在字节跳动,A/B 测试已经是一种企业文化,大家都认可其价值,达成共识才能一起探讨。A/B 测试跟其他环节是紧密相关的。我们在收集和分析数据之后会得到一些洞察,基于这些洞察可以知道有些环节是比较薄弱的,可进行提升,然后就可以提出假设,设计 A/B 实验,完成实验之后评估效果。有可能实验没有达到预期效果,可以对实验进行迭代继续收集数据,这样就形成了以 A/B 测试为核心的业务增长闭环。

下面为大家介绍如何完整进行一次 A/B 实验。

如何产生好的实验想法

关于如何产生好的实验想法,我们可以从定量分析和定性分析几个角度来看。前面提到的构建完善的指标体系就是定量分析,这里不再赘述。在收集到指标数据以后,对于指标发生的异动进行现象分析,针对已存在问题(非异动),则可以进行新的产品策略或者运营策略迭代执行。

定性分析可以分为三个方面:

  • 产品本身的价值主张是什么?比如一款打车 APP 的价值主张是通过共享经济实现社会的效率提升,这个产品有没有很好地体现价值主张?可以从这一方面产生一些实验想法。
  • 推动因素
    • 相关性:同一个页面中如果有不相关的功能,用户大概率也不会点击,这样的设计就没有效果。
    • 清晰度:要表达的内容(比如命名)是否足够清晰。
    • 紧迫性:对于有时间周期的活动,可以设计一些事件营造紧迫感。
  • 阻碍因素:
    • 注意力分散:避免在一个页面放五花八门的信息让用户找不到重点。
    • 焦虑性:有的地方可能给了用户很多选择,也会造成选择困难,不自觉地形成一种焦虑感,不如简单一些只设计一个选择。

如何建立一个有效的实验假设

我们需要针对一个用户群体做出改变,然后产生一定的影响。但是这个假设不是无脑定的,要有逻辑性是合理的,最终能通过指标来评估变化的影响。针对这几个要素,我们总结出了设计 A/B 实验的 PICOT 原则,即 Population、Intervention、Comparison、Outcome、Time,明确对什么样的用户做出了什么样的改变,然后进行分组比较,最终需要设计衡量结果的指标,并决策实验要进行多长时间。

A/B 测试效果评估

看哪些数据

上图是一份 A/B 测试实验报告,可以看到指标在实验版本里是绝对值,还有变化值以及置信区间。置信区间是指假设策略全量上线,你有 95% 的把握会看到真实的指标收益在 [,] 这个范围内。置信区间越窄且不包含 0,可信度就越高。从「查看图表」进入选择差异值可以观察累计 diff 趋势图,如果呈现置信区间逐渐变窄的现象,说明随着样本量越来越大,我们对评估结果的信心就越来越强。

指标变化是显著的吗

A/B 实验的结果有以下几种:

  • 正向显著:说明当前样本容量条件下,实验版本优于对照版本,实验结果和假设一致;
  • 负向显著:说明当前样本容量条件下,实验版本不优于对照版本,实验结果和假设不一致;
  • 不显著:
    • 确实不显著:可以参考 MDE 指标是否符合预期,如果符合,则说明结果确实不显著。
    • 其他原因导致的不显著:比如样本容量小,指标对应的用户行为渗透率低,实验时长较短等。在这些情况下,如果实验效果不显著,可以进一步优化实验,比如增大样本量,扩大流量、再观察一段时间积累更多进组用户等。

接下来我们可以再看两个案例。

哪个首页新 UI 版本更受欢迎

今日头条 UI 整体风格偏大龄被诟病已久,不利于年轻和女性用户泛化,历史上几次红头改灰头实验都对大盘数据显著负向。因此团队设计了 A/B 实验,目标是在可接受的负向范围内,改一版用户评价更好的 UI。通过控制变量法,对以下变量分别开展数次 A/B 实验:

  • 头部色值饱和度
  • 字号
  • 字重
  • 上下间距
  • 左右间距
  • 底部 tab icon
  • 结合用户调研(结果显示:年轻用户和女性用户对新 UI 更偏好)

综合来看,效果最好的 UI 版本如图 2 所示,全量上线。

新 UI 上线后,Stay duration 显著负向从-0.38% 降至 -0.24%,图文类时长显著 +1.66%,搜索渗透显著 +1.47%,高频用户(占 71%)已逐渐适应新 UI。

选择更优的视频上滑引导产品形态

某款短视频在刚面世时,很多用户都不知道上滑的玩法,因此就设计实验验证如何能更好地引导用户上滑。实验目标定为优化后提升新用户留存,上滑操作渗透率提升 1%,错误操作渗透率下降 1%。定向受众为新用户,面向 10% 的线上流量进行为期 1 个月的实验。

我们做了两轮实验,第一轮实验结果并不符合预期,上滑操作渗透率下降 1% 且显著,错误操作渗透率提升 1.5%,不符合预期。新用户留存未见显著上升。但在不符合预期的情况下,还是能做一些分析来发现原因。因此经过改进我们做了第二轮实验,结果上滑操作渗透率上升 1.5% 且显著,新用户 7 日内留存提升 1%-1.8%,且指标结果呈显著,符合预期。

上面的例子就说明了我们可以把 A/B 测试当成一个理解用户的工具。

展望

最后想跟大家一起展望一下 A/B 测试行业未来的情况。从行业前景来看:

  • 认知率和普及率在高速提升:我们之前做过一个调研,发现 A/B 测试在国内整体认知度较低,可能低到一个难以想象的数字。我们认为在未来 5-10 年内,A/B 测试的认知度可能会有 50-100 倍的提升,这个市场还是一片蓝海。
  • 从 nice-to-have 到 must-have:现在很多人认为 A/B 测试是一个锦上添花的工具,但在数据驱动越来越重要的今天,A/B 测试是必须要掌握的工具,是企业开展业务过程中的刚需,否则在行业竞争中就会失去优势。
  • 破圈:我们也发现 A/B 测试正在破圈。大家的印象中 A/B 测试只有互联网公司会用,但是我们在交流的过程中发现,很多传统企业虽然没有线上业务,但如果能解决数据收集的问题,A/B 测试也能满足传统企业优化的诉求。

从技术趋势上来看,有这样几个发展方向:

  • 智能化:A/B 测试目前还处在早期阶段,一些实验结论或实验洞察对数据和用户属性的利用还不是很充分。如果 A/B 测试能和统计方法、算法模型相结合,很可能提高整个行业的水平。
  • 场景化:很多场景还没有开始使用 A/B 测试,未来更多的行业场景能和 A/B 测试相结合,让 A/B 测试更易用。
  • 被集成:目前我们的 A/B 测试平台可以一站式管理实验、查看报告,但是一些用户的业务已经很成熟,希望 A/B 测试能够走入业务和系统,更顺滑地使用。所以 A/B 测试技术也需要提高自身被集成的能力,无缝地和各种业务、系统结合起来。

Q&A

Q:A/B 测试对用户体量有没有基本限制?小用户量在进行 A/B 测试时有什么要注意的吗?

A:A/B 测试方法本身对用户量没有限制,但是如果实验样本太少,就很难看到显著的结果,收益比较小。

Q:火山引擎 A/B 测试和算法等数据科学有哪些结合的尝试和实践?

A:我们内部在做的一些事情可以简单介绍一下:比如基于多臂老虎机的智能实验,已经在开始应用一些算法。此外我们也在探索参数搜索的实验,提升搜索参数的速度,让 A/B 测试更智能化。

Q:A/B 实验一般要测试多才可以看到结果?

A:严格意义上,开多久是和实验能带来的影响有关系的,以我们的经验值来看,一般是要覆盖一个完整的用户生命周期。我们一般是以周为单位,实验至少开启 1-2 周。

Q:A/B 测试在实验结果上有没有自动归因的能力,帮用户直接定位到是什么原因引起实验结果好或者差的?

A:前面提到的一些智能化探索会对自动归因有帮助,但是自动归因还有一个很重要的点是,A/B 测试实验数据背后的原因可能需要很多业务知识的输入或者很有力的指标建设才能推断出来。

Q:如此多的实验,如何保证实验的正交?

A:我们通过大量的模拟实验,以及对系统监控的自检来保证正交,一旦发现数据超过了阈值就会及时进行调整。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

建设一个SaaS平台需要知道什么,做什么(附多图) SaaS

发表于 2021-09-13

SaaS是什么

SaaS,是Software-as-a-Service的缩写名称,意思为软件即服务,即通过网络提供软件服务。 [1]

SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作实际需求,通过互联网向厂商定购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得Saas平台供应商提供的服务。 [2]

SaaS 应用软件有免费、付费和增值三种模式。付费通常为“全包”费用,囊括了通常的应用软件许可证费、软件维护费以及技术支持费,将其统一为每个用户的月度租用费。 [3]

SaaS不仅适用于中小型企业,所有规模企业都可以从SaaS中获利。


以上内容应用自百度百科,以下文字及图片纯属原创,未经授权、禁止转载


SaaS与传统服务、互联网服务的不同

SaaS作为租户系统,需要为租户(C端)提供注册、购买、业务系统的入口,还得为B端(运营/运维)提供租户管理、流量监控、服务状态监控运维入口,示意图如下:

image.png

  • SaaS的服务对象是租户,那么新进入平台未进行服务购买及认证的用户我们暂且称为散户,为了推广平台增加销售成功率,散户登录进入后会跳转进入产品介绍及销售页面,提供详细的产品功能清单及费用信息,提供演示平台供散户进行试用。

传统软件供应商

出售软件及配套设备,将软件部署在客户服务器或客户指定云服务器,出售的软件系统及运维服务为盈利来

image.png

互联网应用供应商

服务器部署在云端,所有用户可以通过客户端注册进行使用,广告及付费增值服务作为盈利来源
image.png

SaaS应用供应商

介于传统与互联网之间,通过租用的方式提供服务,服务部署在云端,任何用户通过注册后进行订购后获得需要的服务,可以理解成服务器及软件归供应商所有,用户通过付费获得使用权

image.png

由上面服务提供模式关系图可以看出,三种模式虽然不同,但最终都是靠应用系统盈利,最终是什么样的模式取决行业类型和主要客户群体

SaaS的特征

我们清楚了SaaS的定义及与传统服务和互联网服务的差异,明确了SaaS系统的两大大特征:

  • 1.部署在供应商的服务器上,而不是部署在甲方的服务器上
  • 2.订购模式,服务商提供大量功能供客户选择,客户可以选择自己需要的进行组合,支付所需的价格,并支持按服务时间付费

我们去租房,可以长租也可以短租,可以租单间也可以租套房,最终形成每月租金进行付费;租房的情况下,可能是情侣租房,也可能是三口之家租房,一个租户就有1n个人,所以SaaS平台面对的第一对象是一个租户(这个租户的规模可以是1n个人),对于一个互联网平台,租户中人员规模决定了流量大小,所以租户中的人员规模也是定价的参考项。

SaaS如何量化定价

  • 选购功能数目
  • 租户人员规模
  • 服务期限(时间长可以有相应的优惠)
  • 技术支持(售后以外的支持)

SaaS服务的构成

  • 租户管理、一租户多用户/用户即租户
  • 租户页面级别权限
  • 租户数据隔离
  • 运营模块及人员
  • 产品介绍及销售

SaaS服务商可以理解成一个包租公,他有一栋楼(SaaS服务),所有人都可以来租(多租户),租大的租小的都行(按需选择),但是每个租户只能进入自己租的房子(安全隔离),用户租户到期前提醒(服务期限提醒),到期后选择续费或搬家(继续服务或停止服务),物业人员提供配套服务(运营及售后)

SaaS服务体系

谁在做SaaS

通常提供SaaS服务的厂家并不是生来就是SaaS服务商,主要来自于以下两种企业:

  • 传统服务商向互联网转型,将产品云化,SaaS化,这样有两个好处:1.降低用户成本,用户无需购买服务器,支持服务订购,可以根据需求购买部分模块;2.作为供应商,可以降低运维成本,且能够覆盖不同需求的用户
  • 互联网企业增加B端服务板块,能够将企业产品价值最大化、覆盖到更多用户群体,比如钉钉、企业微信等

产品如何SaaS化

  • 1、进行云化部署,性能升级,能够支持更大规模的用户访问
  • 2、用户系统改造,支持2C用户登录(手机号一键登录、小程序登录、短信验证码登录)
  • 3、网关服务,限流,接口防篡改等等
  • 3、租户系统开发,包含租户基础信息管理、租户绑定资源(订购的功能)、租户服务期限等等
  • 4、客户端改造(通常SaaS系统主要提供WEB端服务),页面权限控制,根据租户系统用户资源提供用户已购买的模块或页面
  • 5、官网开发,功能报价单,功能试用、用户选购及支付
  • 6、服务端接口数据权限改造、租户级别数据权限

租户验证及数据隔离

image.png

总结

SaaS系统是介于2b与2c业务之间的一种服务,提供2c式入口,2b式的系统平台,无论对于服务提供商还是客户都是非常好的一种模式。对于我们开发人员来说,了解SaaS要做什么,做好技术储备,就能知道怎么做了。

点击了解登录失败锁定

点击了解低代码平台

点击了解mongo索引设计

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

mica-mqtt 112 发布,基于 redis pu

发表于 2021-09-13

一、简介

mica-mqtt 基于 t-io 实现的简单、低延迟、高性能 的 mqtt 物联网开源组件。使用详见 mica-mqtt gitee 源码 mica-mqtt-example 模块。

在多个朋友咨询 mica-mqtt 集群之后我添加了一个 mica-mqtt-broker 模块演示了基于 redis pub/sub 实现集群实现。

二、功能

  • 支持 MQTT v3.1、v3.1.1 以及 v5.0 协议。
  • 支持 websocket mqtt 子协议(支持 mqtt.js)。
  • 支持 http rest api,http api 文档详见。
  • 支持 MQTT client 客户端。
  • 支持 MQTT server 服务端。
  • 支持 MQTT 遗嘱消息。
  • 支持 MQTT 保留消息。
  • 支持自定义消息(mq)处理转发实现集群。
  • MQTT 客户端 阿里云 mqtt 连接 demo。
  • 支持 GraalVM 编译成本机可执行程序。
  • 支持 Spring boot 项目快速接入(mica-mqtt-spring-boot-starter)。
  • mica-mqtt-spring-boot-starter 支持对接 Prometheus + Grafana。
  • 基于 redis pub/sub 实现集群,详见 mica-mqtt-broker 模块。

三、待办

  • 优化处理 mqtt session,以及支持部分 mqtt v5.0 新特性。

四、更新记录

  • ✨ 添加 mica-mqtt-broker 模块,基于 redis pub/sub 实现 mqtt 集群。
  • ✨ mica-mqtt-broker 基于 redis 实现客户端状态存储。
  • ✨ mica-mqtt-broker 基于 redis 实现遗嘱、保留消息存储。
  • ✨ mqtt-server http api 调整订阅和取消订阅,方便集群处理。
  • ✨ mica-mqtt-spring-boot-example 添加 mqtt 和 http api 认证示例。
  • ✨ 添加 mqtt 5 所有 ReasonCode。
  • ✨ 优化解码 PacketNeededLength 计算。
  • 🐛 修复遗嘱消息,添加消息类型。
  • 🐛 修复 mqtt-server 保留消息匹配规则。

五、Spring boot 快速接入

5.1 添加依赖

1
2
3
4
5
xml复制代码<dependency>
<groupId>net.dreamlu</groupId>
<artifactId>mica-mqtt-spring-boot-starter</artifactId>
<version>1.1.2</version>
</dependency>

5.2 服务端配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
yaml复制代码mqtt:
server:
enabled: true # 是否开启,默认:true
ip: 127.0.0.1 # 服务端 ip 默认:127.0.0.1
port: 5883 # 端口,默认:1883
name: Mica-Mqtt-Server # 名称,默认:Mica-Mqtt-Server
buffer-allocator: HEAP # 堆内存和堆外内存,默认:堆内存
heartbeat-timeout: 120000 # 心跳超时,单位毫秒,默认: 1000 * 120
read-buffer-size: 8092 # 接收数据的 buffer size,默认:8092
max-bytes-in-message: 8092 # 消息解析最大 bytes 长度,默认:8092
debug: true # 如果开启 prometheus 指标收集建议关闭
websocket-enable: true # 开启 websocket 子协议,默认开启
websocket-port: 8083 # websocket 端口,默认:8083

5.3 服务端可实现接口(注册成 Spring Bean 即可)

接口 是否必须 说明
IMqttServerAuthHandler 是 用于客户端认证
IMqttMessageListener 是 消息监听
IMqttConnectStatusListener 是 连接状态监听
IMqttSessionManager 否 session 管理
IMqttMessageStore 集群是,单机否 遗嘱和保留消息存储
AbstractMqttMessageDispatcher 集群是,单机否 消息转发,(遗嘱、保留消息转发)
IpStatListener 否 t-io ip 状态监听

5.4 Prometheus + Grafana 监控对接

得益于 t-io 良好的设计,监控指标直接对接的 t-iostat,目前支持下列指标,后期会不断完善。

支持得指标 说明
mqtt_connections_accepted 共接受过连接数
mqtt_connections_closed 关闭过的连接数
mqtt_connections_size 当前连接数
mqtt_messages_handled_packets 已处理消息数
mqtt_messages_handled_bytes 已处理消息字节数
mqtt_messages_received_packets 已接收消息数
mqtt_messages_received_bytes 已处理消息字节数
mqtt_messages_send_packets 已发送消息数
mqtt_messages_send_bytes 已发送消息字节数

mqtt监控1.jpg

关于 mica-mqtt-spring-boot-starter 更多请查看文档:gitee.com/596392912/m…

六、普通 java 项目接入

6.1 maven 依赖

1
2
3
4
5
xml复制代码 <dependency>
<groupId>net.dreamlu</groupId>
<artifactId>mica-mqtt-core</artifactId>
<version>1.1.2</version>
</dependency>

6.2 mica-mqtt 客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码 // 初始化 mqtt 客户端
MqttClient client = MqttClient.create()
.ip("127.0.0.1")
.port(1883) // 默认:1883
.username("admin")
.password("123456")
.version(MqttVersion.MQTT_5) // 默认:3_1_1
.clientId("xxxxxx") // 默认:MICA-MQTT- 前缀和 36进制的纳秒数
.connect(); // 连接

// 消息订阅,同类方法 subxxx
client.subQos0("/test/#", (topic, payload) -> {
logger.info(topic + '\t' + ByteBufferUtil.toString(payload));
});
// 取消订阅
client.unSubscribe("/test/#");

// 发送消息
client.publish("/test/client", ByteBuffer.wrap("mica最牛皮".getBytes(StandardCharsets.UTF_8)));

// 断开连接
client.disconnect();
// 重连
client.reconnect();
// 停止
client.stop();

6.3 mica-mqtt 服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码 // 注意:为了能接受更多链接(降低内存),请添加 jvm 参数 -Xss129k
MqttServer mqttServer = MqttServer.create()
// 默认:127.0.0.1
.ip("127.0.0.1")
// 默认:1883
.port(1883)
// 默认为: 8092(mqtt 默认最大消息大小),为了降低内存可以减小小此参数,如果消息过大 t-io 会尝试解析多次(建议根据实际业务情况而定)
.readBufferSize(512)
// 自定义认证
.authHandler((clientId, userName, password) -> true)
// 消息监听
.messageListener((clientId, topic, mqttQoS, payload) -> {
logger.info("clientId:{} topic:{} mqttQoS:{} message:{}", clientId, topic, mqttQoS, ByteBufferUtil.toString(payload));
})
.debug() // 开启 t-io debug 信息日志
.start();

// 发送给某个客户端
mqttServer.publish("clientId","/test/123", ByteBuffer.wrap("mica最牛皮".getBytes()));

// 发送给所有在线监听这个 topic 的客户端
mqttServer.publishAll("/test/123", ByteBuffer.wrap("mica最牛皮".getBytes()));

// 停止服务
mqttServer.stop();

七、集群演示

mica-mqtt集群.gif

八、关注我

关注如梦技术掘金专栏更多精彩好文等你来发现。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用springboot给小伙伴输出一波月饼

发表于 2021-09-13

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

眼瞅着中秋节了,可是博主在小公司也不给发月饼,同时响应社区博文活动的号召,是不是自制一波月饼。那么本文将使用springboot给小伙伴们输出一波月饼。俗话说的好:礼轻情意重。希望我的小伙伴不要爆锤我。

1.制作图

这里博主推荐一个网站www.degraeve.com/img2txt.php
可以通过图片制作ASCII图片。

1.制作

image.png
可以将图片复制到掘金社区,然后就会生成图床,我们可以将生成的地址复制进去。然后点击ASCLLFY就可以制作图片啦。

也可以通过width of output 更改图片大小。

2.图的效果

image.png

2.替换springboot banner

然后我们需要将该图复制进spring boot项目。需要在控制台打印。

1.控制台输出月饼

这个步骤很简单:

  1. 新建banner.txt文件。
  2. 将上文新建的文件复制进\src\main\resources\banner.txtt路径,如下图。
  3. 直接启动项目即可。
    image.png

2.输出的文本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
less复制代码                                            fDGi
:KEi.:fEL
;K jK.
tEEEi.. .Ei L; ,GEEG:
.fKi. .tEK. Kt E: GKG. DE,
.K. ,K Ef iD.
W iGf, .DL
.j: E; .t. E
E . , G D
D. ,G:.;Gi ,: .j ..Df. iK: K
E ,i. .E Lt G ,L
. iGEEKDi ., G. WEEKEf.
LKj. . G. : ; ;EE.
K. , , fE
K. D . .it
Di ; ; L K
D .D; . Et LKEEEK:. jG .D
:: L: i KEGEKEEKEKEEL. , t K
G D i DKEKEEEEEEEEE , :: G,
K i .WEEEEEEEEEKi , L it
K D i tEEEEEEj , ;f
K ; i ;EKKEj : i ;f
D. i tEK,;DKE,. : G ti
tKE i fKEE,EEKKEEK : E iEE.
DE i EEEEKKKEEKKEj : E ;E:
. E; : i KEED EKEEKEKi : E EG
K. iD i EEEf..GWEEK.. : ,E ,f
K, E i EEEE DEEEE : ,t E.
E L i DKEEEKEEEEG. : G .K
; i i ,KEKKKEKEKi : G E
D E .i KEEKKKf : , f
D ; iG: .E .KEEK .KKEK Et..ti f i
D D L WEEEKL EKWf i; j D f
t f fED tEEKEf.WEED j jDD: G G E
t : ,.: : f :EEEE.EEi E i t , D E
E , .E t DD G G EKKEEEEKKKKKGi, G LGj ; E f ;f
.E. G G L ;D f .EEEKKKEEDj;,,iEEKKt D.,: .. , f . .E.
K .G.j .::, G EtLKEKKEKKE: EKEK E .i; G t , E E j
Ej E : f i. : EKEEEEEEK; iKE ;. .j f , i E. . K,
E L : K j i f DEEEEEED .. i; G t E; G L f;
D; ;.. E j, i .tEE. EKKEK: .DE; i ,i f D
G ; : ., G G i , D : : L :E
E f E K fL DGj tjD.. :L G L , tL
K . : ; ; i , .i.L . : G K
ij : D i. G .G. i , j.L.j D ,: . E
L: L .i K .: ,. ,EE LKi G . j . G
L, f :t D t ..;L t , iE.G ; j j . G
:G . . . j ; i , ;i.f . .i K
K . G D DG DGj tLD .E L G E K
G D ; ; D .t i , :j j i i . D;
E D E E i ;EE. . KE,. i ;L : : tG
;D , :. E f i f it D j E. D E K
L; .t. G L t .i ; : f L D .K .
jK j. .E j ::.: D Dj. KG; E ,i; D j i D ; E
.K ; :f i tE f fEEE WKKD D :i . j t jj D .D
E f D t GD E L EKDK EKE, E . DLt i .D , E:
.E . ...t: .. L EEKj EEK E ; i , G .E
., . D .;L.i, . Ki. EEK :. .G.tE .. .D K
L . D. L : EEK .i; j. G D
E .E: . ,f, . EKE :EDf: ,t E D j
E t i EE W:. KKKj :EEKKL , ; t
L f i LKE tDDE KKE KKEEE , f L
,. .E i KK; E.EK EKEKKt LEG , : E
K .f i GEEKK.KK iEKKKKE, , i; .tL
Gf ji i GEEKKiEEGKEKKKKL , E .E
Dj if i DKKEELEEEEWEKE. , ,E; L:
iD. . i EEKEiEEEE.EEE; , E iK.
,Ej i KKEE .EEE EKE , E :KL
LE i GKKEE :fiEKL , E jD:
E . i .EEEE, KEE , L ;j
E t i EEEEE EEEt , , ,f
K j i tEEE ,KEE , . ,f
E i EEE KKE. , K ji
f L i f EKEK , L G.
i ,E : .EEEK . .E E
E iEf;if tKKE. jjitGL iL
jG . ; EKEEi i W
Gf D. :EKKf . W:
Gf , ;jGj f .Ej
.DEEjitf i . ;. GfitfEK;
,tiif .G . . .; D;ti,
K. E . t,; .LD. .i. jt
ft fKDDt G G jEED, K
E j .L .t
:f :E: iE .K
Di . . . K.
KG .EKt EK; E,
EEGfGEKf fE K GEEffDKL .
.,: fE W ,,.
Ef . KL
jEKDEKE
.

我跟老板说了,今年用这个就当月饼了!!!!!!!

貌似在复制进idea后,有点倾斜。但是不影响效果!!!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

详解哈希数据结构,手写哈希表

发表于 2021-09-13

本文为掘金社区首发签约文章,未获授权禁止转载。

哈希表,终于姗姗来迟了。

本文系统讲解了哈希表数据结构的相关概念,并以HashMap为案例讲解一下它与普通哈希表的不同点,最后也手写一个简易的哈希表。

所以通过本文,我希望读者们能对哈希表有一个清楚的认识,尤其是在Java面试过程中,HashMap的相关面试题几乎是逢面必问,如果你连哈希表结构都不清楚,那真的很难从根上理解HashMap。

除了面试,在你掌握了哈希表之后也可以根据应用场景的需要,对哈希函数进行重写,以此来保证在你的应用场景下哈希分布的更加均匀。

本文概览如下:

  1. 哈希表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

哈希作为一个非常常用的查找数据结构,它能够在O(1) 的时间复杂度下进行数据查找。

比方说我有一个集合有如下数据,而我想要快速查找一个数据在不在这个集合中,我应该采取什么办法?

一般情况下可以使用遍历的方式,但是如果数据量太多,则每次遍历的代价将不可接受。

那么,如果它们是有序的,则可以使用树形数据结构进行二分查找,效率也是非常的高,但很不巧我们这些数据是无序的。

所以就有人想到一个很巧妙的办法来寻找它,就是将要寻找的数据(下文称为键)进行一次计算得到一个数组下标值,然后将这个值放到对应的数组里。

以后我们每次寻找的时候都对键进行计算从而得到一个数组下标值,然后通过下标拿到数组对应的数据,就能知道它是否存在于这个数组中了。

这种数据查找的数据结构就叫做哈希表,对键的计算的方法叫做哈希函数。

在Java中,典型的Hash数据结构的类是HashMap。


我们回顾一下哈希表的执行步骤:

  1. 对键进行哈希函数计算,得到一个哈希值。
  2. 对哈希值进行计算得到一个数组下标。
  3. 通过数组下标得到数组中对应的数组。

由于哈希表的查找步骤与哈希函数都是恒定不变的,所以哈希表的时间复杂度为O(1)。

  1. 哈希函数

哈希函数是一种提取数据特征的算法,针对不通的数据形式有不同的哈希算法,所以哈希函数并不通用,针对不同场景有很多不同的哈希算法,比如我们常见的MD5就是一种获取文件信息摘要的哈希算法。

再比如在Java中,对于常用的数据类型,JDK就实现了多种不同的hash函数。

Integer:

1
2
3
java复制代码    public static int hashCode(int value) {
return value;
}

Integer的哈希函数就是直接拿到它的值。

String:

1
2
3
4
5
6
7
8
java复制代码    public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
hash = h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
}
return h;
}

对于字符串类型则是使用了一个这样一种算法:s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]。

Double:

1
2
3
4
Java复制代码    public static int hashCode(double value) {
long bits = doubleToLongBits(value);
return (int)(bits ^ (bits >>> 32));
}

浮点类型则是使用位运算的方式进行哈希计算。

读者们可能会有疑惑,为什么要对这么多数据结构实现不同的哈希计算方法,这主要还是为了哈希值的均匀。

哈希值越均匀,就说明哈希函数设计的越好,也预示的哈希冲突的减少,关于哈希冲突,将在下节讲到。


在第一节的时候我说过,除了计算哈希值,我们还需要计算数组的位置。

计算数组位置有很多方法可用,这里我就介绍比较简单的除留余数法:

假设我对中国人这个key进行计算,得到了一个哈希值:19942986,那么我将用这个数字对数组的大小进行取余,这里我假设自己的数组大小是11,就得到这样的计算公式:

19942986 % 11 = 8

那么,我们就得出了本次哈希函数的结果为数组下标8,我们就可以将中国人这个字符串放到了数组下标8的位置上。

既然哈希值计算希望越均匀越好,那么数组下标是否也有类似的需求呢?

还真有,比如我们上面的除留余数法,在除留余数法中,数组大小的选择将深刻影响着取余结果是否均匀,所以在除留余数法中,我们一般使用质数,这也是为什么HashTable的初始化大小为11,因为11是一个质数。

  1. 哈希冲突

经过前文的学习后,相信大家对哈希表的相关概念都已经清楚了,那么本节就来学习哈希表的一大重点:哈希冲突。

哈希冲突是指多个不同的键散列到了同一个数组下标位置上,案例如下:

在上图中,耳、朵、不这三个字经过散列之后的数组下标都是0,而且因为是三个不同的值,所以也不能直接在数组上覆盖,那么我们就需要有一个办法把这三个值存起来。

这里将介绍两种常用的方法:开放地址法和链地址法。

开放地址法是一种比较简单的解决冲突的方法,它的原理非常简单:

就是在第一个耳字已经占用了下标0之后,第二个朵字则向后进行寻找空余的下标,找到之后将自己设置进去,所以朵字在下标1处,而不字在下标2处。

根据寻找下标的方式不同,开放地址法可以分为以下几种:

  1. 线性探测法:下标被占用之后,以步长为1依次向后寻找,上图例中我使用的就是这种方法。
  2. 二次探测法:下标被占用之后,步长为2的倍数,依次向后寻找。
  3. 伪随机探测法:下标被占用后,随机出一个数字,然后使用这个数字作为下标进行寻找,这种方法全靠天意了。

其实原理都差不多,都是在当前下标的基础上向后寻找空余的下标,不过步长不一样罢了。


链地址法俗称拉链法,就是在冲突的下标元素处维护一个链表,所有冲突的元素都依次放到这个链表上去:

在上图中,我将冲突的两个键就按照顺序放在了链表中,下次寻找时只需要查看该数组元素以及遍历这个链表即可,在Java中的HashMap中就是用了这种方法进行解决哈希冲突。

以上两种方法都有可能随着冲突的增多,导致查找效率变低,所以没有一个完美的方案可以解决哈希冲突的问题,我一般推荐使用拉链法,因为它比较简单易理解。

  1. HashMap案例

前文中大量提到了HashMap,这一节就将对HashMap中的一些和上文讲述不一样的关键点进行梳理。

首先是HashMap的表容量,表容量就是这个哈希表中可以装下多少个元素。

前文我已经说过,在哈希表中最好使用质数作为表的容量,并且还拿了HashTable作为举例。

而在HashMap中,它的初始容量是16,这不光是一个偶数还是2的倍数。

HashMap之所以没有使用质数而是使用2的倍数作为它的容量,是因为它在计算数组下标时没有使用除留余数法,而是使用了位运算进行计算数组下标。

1
2
3
4
java复制代码    static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

计算完哈希值之后,则是使用哈希值和数组长度进行位运算:(tab.length - 1) & hash。

  1. 手写哈希表

了解了上面的哈希知识之后,就可以自己写一个类似hashMap的数据结构了。

首先呢,我们先来想一下做一个哈希表的必要条件:

  1. 哈希函数
  2. 哈希冲突方法

只要牢记这两点,写出符合要求的数据结构即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码public class DiyHashMap{
private Object[] tables;
private int size;

public DiyHashMap() {
size = 11;
tables = new Object[size];
}

public Object get(String key) {
int index = hash(key) % size;
if (tables[index] == null) {
return null;
} else {
return tables[index];
}
}

public void put(String key, Object obj) {
int index = hash(key) % size;
tables[index] = obj;
}

private int hash(String key) {
int hash = 0;

for (int i = 0; i < key.length(); i++) {
hash = hash + (key.charAt(i) * 31 + i);
}

return hash;
}

}

在我的示例中,使用了字符串作为哈希表的键,而hash函数则是简单的依次乘以31相加得出,这里的返回值是一个int类型,所以哈希值最大是32位。

在哈希映射的步骤上,我使用了上文讲过的除留余数法,同时表的大小设置为了一个质数:11。

除了这些之外,还有两点可以加以扩展:

  1. 表可以进行扩容,扩容大小可以为原来的两倍并+1,尽可能使其是质数。
  2. 使用拉链法解决冲突,我目前的做法是直接覆盖,使用拉链法可以将表的Object数组换成别的抽象数据类型数组,并多加一个双向链表引用即可。
  1. 结语

好了,以上就是哈希表的全部内容了,当然了,哈希也是有缺点的:

  1. 哈希冲突太多会导致部分查找线性。
  2. 不能进行范围查找。

对于第一个缺点,一般正常使用是不会出现太多冲突,而且在Java中,冲突太多则会自动转换为红黑树数据结构。

对于第二个缺点,这是哈希表一个无法回避的硬伤,在需要范围的查找的需求下,还是树类数据结构更能兼顾范围查找与指数级速度。

当然哈希表的速度足以掩盖很多缺点,它在很多应用中都有使用,比如redis本身就是一个大大的哈希表,数据库中的索引也往往有着哈希索引的选项,还有操作系统的注册表之类的快速查找都会使用哈希表数据结构,就像一白遮百丑一样,哈希,只要快就够了。
最后,如果本文对大家有帮助,希望不要吝啬点赞收藏与关注,你们的支持就是我的不懈动力,我们下个文章见。


推荐阅读

  1. 树、二叉树与手写AVL,树型数据结构详解
  2. 数组、链表、队列和栈,四大基础数据结构详解

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

nom -- 乐高式富有语义的parser

发表于 2021-09-13

简介

写过parser的人,不管是简单的自定义协议,或者复杂的协议,一般都是采用自上往下的解释方式,从第1个字节,一路开黑,到最后字节。遇到;用一个判断,遇到:用一个match等等,switch相应的case,所谓遇神拜神,遇鬼杀鬼,遇佛却不知所措。这样的问题是,加上错误处理,if else可能会过于复杂而凌乱,时间久了,难以维护。稍微高端点的,可能会写出几个复杂一点的正则表达式,不过最后也很有可能,最终忘记当初写这正则的含义。高端玩家估计就用lex/yacc flex/bison,的确好用又好维护,除了增加一下描述文件,增加一些与开发语言无关的东西。不过杀鸡焉用牛刀,这么庞大的工具,有必要割本来就小的小JJ?!

nom提供一种比较清奇的思路,估计作者深受乐高的熏陶,才有这种创造。我们知道,乐高玩具,提供的是很限的几个基础形状模块,通过一个个模块的组合起来,就可以实现各种物件的创作,栩栩如生。nom的思路并不是教你如何像lex/yacc构造特定语法的模板文件,也不期待你自顶而下解释,而是像乐高一样,提供很多基础的基础形状,如tag, take_while1, is_a等,并提供组合一起的方法,如alt, tupple, preceded等。用各种方法可以组合,形成各式各样的parser,而又不损失任何性能。

nom函数的设计高度一致,几乎所有函数的调用,都返回带相同签名的函数:

1
2
3
4
5
6
7
8
rust复制代码// 一般函数返回
impl Fn(Input) -> IResult<Input, Output, Error>
​
// IResult定义
pub type IResult<I, O, E = error::Error<I>> = Result<(I, O), Err<E>>;
​
// ParseError<I> 为 () 实现了,所以一般可以用(),方便使用ParseError
impl<I> ParseError<I> for ()

nom的基础函数构造块,分两个版本,一个是complete和stream版本,两个直观上最大的区别是报错方式,stream版本报错为Err::Incomplete(n),complete直接报Err::Error/Err::Failure。除此之后,使用上并没有明显的差异。

nom的parser和combinator应有尽有,没有的也可以组合出来。除了docs.rs的文档,作者还贴心的列出来(因为rust生成的文档不方便一起看),这里不累赘,参考作者介绍choosing_a_combinator。

示例

tokio的官方教程tutorial是学习rust很好的一个开始的地方,把之前C/C++已有的概念用rust实现了一遍,既亲切又熟悉。tokio的官方教程tutorial是实现简单redis功能,代码仓库位于: mini-redis。 mini-redis对于redis协议的解析是通过一个字节解析的,幸好简单,所以原实现并不凌乱。现修改为用nom解析方式,代码仓库位于: github.com/buf1024/blo…,协议解析的文件:frame.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scss复制代码/// A frame in the Redis protocol.
/// 协议格式
#[derive(Clone, Debug)]
pub enum Frame {
   // +xxx\r\n 简单的string
   Simple(String),
   // -xxx\r\n 错误的string
   Error(String),
   // :ddd\r\n u64数值
   Integer(u64),
   // $dd\r\nbbbbb\r\n 有内容的chunk
   Bulk(Bytes),
   // $-1\r\n 空Chunk
   Null,
   // *dd\r\nxxx\r\n array dd 我数量
   Array(Vec<Frame>),
}

mini-redis简单实现了redis的5个基本协议,实现并不复杂,拼拼凑凑就成了,魔改的nom的版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
scss复制代码fn parse_simple(src: &str) -> IResult<&str, (Frame, usize)>
  {
       let (input, output) = delimited(tag("+"), take_until1("\r\n"), tag("\r\n"))(src)?;
       Ok((input, (Frame::Simple(String::from(output)), src.len() - input.len())))
  }
​
   fn parse_error(src: &str) -> IResult<&str, (Frame, usize)>
  {
       let (input, output) = delimited(tag("-"), take_until1("\r\n"), tag("\r\n"))(src)?;
       Ok((input, (Frame::Error(String::from(output)), src.len() - input.len())))
  }
​
   fn parse_decimal(src: &str) -> IResult<&str, (Frame, usize)>
  {
       let (input, output) = map_res(
           delimited(tag(":"), take_until1("\r\n"), tag("\r\n")),
          |s: &str| u64::from_str_radix(s, 10),
      )(src)?;
       Ok((input, (Frame::Integer(output), src.len() - input.len())))
  }
​
   fn parse_bulk(src: &str) -> IResult<&str, (Frame, usize)>
  {
       let (input, output) = alt((
           map(tag("$-1\r\n"), |_| Frame::Null),
           map(terminated(length_value(
               map_res(
                   delimited(tag("$"), take_until1("\r\n"), tag("\r\n")),
                  |s: &str| u64::from_str_radix(s, 10)),
               rest),
                          tag("\r\n"),
          ), |out| {
               let data = Bytes::copy_from_slice(out.as_bytes());
               Frame::Bulk(data)
          }))
      )(src)?;
       Ok((input, (output, src.len() - input.len())))
  }
​
   fn parse_array(src: &str) -> IResult<&str, (Frame, usize)>
  {
       let (input, output) =
           map(length_count(
               map_res(
                   delimited(tag("*"), take_until1("\r\n"), tag("\r\n")),
                  |s: &str| {
                       println!("{}", s);
                       u64::from_str_radix(s, 10)
                  }),
               Frame::parse,
          ), |out| {
               let data = out.iter().map(|item| item.0.clone()).collect();
               Frame::Array(data)
          },
          )(src)?;
       Ok((input, (output, src.len() - input.len())))
  }
​
   pub fn parse(src: &str) -> IResult<&str, (Frame, usize)>
  {
       alt((Frame::parse_simple, Frame::parse_error, Frame::parse_decimal, Frame::parse_bulk, Frame::parse_array))(src)
  }

可以看到nom的版本有很简洁的方式,甚至一行代码即可实现,不需要原代码那样,一个字节一个字节的判断,来操控和移动内存位置。就类似乐高积木一样,一个一个的叠起来,简单又简洁。

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
css复制代码^_^@~/rust-lib/mini-redis]$ RUST_LOG=debug cargo run --bin mini-redis-server
  Compiling mini-redis v0.4.0 (~/rust-lib/mini-redis)
  Finished dev [unoptimized + debuginfo] target(s) in 7.44s
    Running `target/debug/mini-redis-server`
Sep 13 00:10:16.517 INFO mini_redis::server: accepting inbound connections
Sep 13 00:10:40.261 DEBUG mini_redis::server: accept address from 127.0.0.1:55931
5
Sep 13 00:10:40.264 DEBUG run: mini_redis::connection: nom frame: Array([Bulk(b"set"), Bulk(b"hello"), Bulk(b"world"), Bulk(b"px"), Integer(3600)]), n: 50
Sep 13 00:10:40.264 DEBUG run: mini_redis::server: cmd=Set(Set { key: "hello", value: b"world", expire: Some(3.6s) })
Sep 13 00:10:40.264 DEBUG run:apply: mini_redis::cmd::set: response=Simple("OK")
^CSep 13 00:10:52.278 DEBUG my_exit: mini_redis_server: press once more to exit
^CSep 13 00:10:52.934 DEBUG my_exit: mini_redis_server: now exit
Sep 13 00:10:52.934 INFO mini_redis::server: shutting down
​
^_^@~/rust-lib/mini-redis]$ RUST_LOG=debug cargo run --bin mini-redis-cli set hello world 3600
  Compiling mini-redis v0.4.0 (~/rust-lib/mini-redis)
  Finished dev [unoptimized + debuginfo] target(s) in 1.61s
    Running `target/debug/mini-redis-cli set hello world 3600`
Sep 13 00:10:40.262 DEBUG set_expires{key="hello" value=b"world" expiration=3.6s}: mini_redis::client: request=Array([Bulk(b"set"), Bulk(b"hello"), Bulk(b"world"), Bulk(b"px"), Integer(3600)])
Sep 13 00:10:40.265 DEBUG set_expires{key="hello" value=b"world" expiration=3.6s}: mini_redis::connection: nom frame: Simple("OK"), n: 5
Sep 13 00:10:40.265 DEBUG set_expires{key="hello" value=b"world" expiration=3.6s}: mini_redis::client: response=Some(Simple("OK"))
OK
​

小结

rust的nom提供一种类似于乐高积木的方式去构造解析器,通过组合各种基本的parser,完全可以构造出足够复杂的解析器,同时不会因为组合各种parser,而失去任何性能。同时,得益于其命名方式和统一的函数返回形式,基于nom的解析器,语义上比那些get_line之类的,清晰太多。所以在解析任务上,除了有现成而又成熟的解析库,完全可以考虑采用的,而非get_line,get_u8那样一个字节的或者正则那样一个模式的,开黑下去……

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Typora+PicGo+Gitee搭建博客写作环境 Typ

发表于 2021-09-12

Typora+PicGo+Gitee搭建博客写作环境

在学习的过程中,很多同学都有记笔记的习惯,记笔记的工具有很多,大概有以下几种:

  • Word
  • oneNote
  • 印象笔记
  • 语雀
  • Markdown
  • …….

总之,记笔记的软件五花八门,而程序员作为特殊的群体,他们热衷于钻研技术、分享技术,因此免不了写文章发布在各类博客平台。为了同非专业人士区分开来,程序员通常采用Markdown写作,撰写好的文章的会发布在多个博客平台,因此免不了图片复用。

本文以图片复用为出发点,详细阐述了如何使用Typora+PicGo+Gitee搭建我们的博客写作环境。

  • Typora:它是一款轻便简洁的Markdown编辑器,支持即时渲染技术,即所写立刻所见,少了排版的时间,专注于文章内容的编辑。
  • PicGo:它是一个用于快速上传图片并获取图片 URL 链接的工具,支持多种图库。
  • Gitee:目前中国最大的代码托管的工具,除了代码,还可以用作图片存储。

本文共分为三部分,Typora和PicGo软件的安装、PicGo+Gitee图床环境的搭建和Typora接入图床。

1、Typora和PicGo

1.1 Typora

  • Typora介绍

Typora 是一款支持实时预览的 Markdown 文本编辑器。它有 OS X、Windows、Linux 三个平台的版本,并且由于仍在测试中,是完全免费的。

它首先是一个 Markdown 文本编辑器,它支持 Markdown 语法的文本编辑。在 Typora 官网上他们将 Typora 描述为 「A truly minimal markdown editor. 」

  • 系统

本文使用的系统环境是windows10

  • 下载

官网下载地址:www.typora.io/#windows

image-20210804213716829

根据自己的需要选择性的下载即可。

安装步骤比较简单,一直点击next即可。

1.2 PicGo

所谓图床工具,就是自动把本地图片转换成链接的一款工具,网络上有很多图床工具,就目前使用种类而言,PicGo 算得上一款比较优秀的图床工具。它是一款用 Electron-vue 开发的软件,可以支持微博,七牛云,腾讯云COS,GitHub,阿里云OSS,SM.MS,imgur 等7种常用图床,功能强大,简单易用。

  • 下载

下载地址:github.com/Molunerfinn…

NOTE:mac系统选择dmg下载,windows选择.exe下载。

image-20210804214907164

image-20210804214907164

安装过程很简单,一直next即可。

2、PicGo+Gitee图床环境的搭建

2.1 创建仓库

在自己的Gitee上创建一个仓库,仓库必须是公开(开源)的。

image-20210803232332445

2.2 设置私人令牌

  • 在gitee点击自己的头像,并找到设置按钮。

image-20210803232706236

  • 在安全设置中找到私人令牌

image-20210803232813590

  • 输入私人令牌描述和gitee密码

image-20210803232957498

  • 全部完成后,即可生成私人令牌,

image-20210803233119378

NOTE:一定要保存好令牌,页面关闭后,将再也找不到本次生成的令牌,若令牌丢失,可以重新生成新的令牌

2.3 PicGo的设置

2.3.1 设置Gitee图床

在图床设置中选择Gitee图床

image-20210803233435291

  • owner:写自己的用户名即可
  • repo:用户名/仓库名称,比如我自己的仓库Langyk/image_repository,也可以直接复制仓库的url

image-20210803233739059

image-20210803233739059

  • 这个是可选项,可以不填写
  • token:这个就是我们生成的私人令牌,直接复制到里面
  • message:这个是非必填项,不影响

2.3.2 PicGo设置

  • 设置开机启动和时间戳重命名

image-20210803234305235

2.3.3 插件设置

在搜索框中搜索gitee,并安装该插件

NOTE: 这里注意一下,必须要先安装node.js才能安装插件,没装的自己装一下,然后重启就行。

image-20210803234456302

3、Typora接入图床

有了前面的工作后,就可以在Typora中接入图床了

在Typora中的左上角选择文件--->偏好设置--->图像,

按照下图进行配置

image-20210803234944722

image-20210803234944722

  • 最后点击验证图片上传选项检验图床是否接入成功,成功时则会显示验证成功。

image-20210803235212995

到此,我们的写作环境就搭建好了。

我是Simon郎,一个每天都想博学一点点的小青年,期待你的关注,我们下篇文章见。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…532533534…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%