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

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


  • 首页

  • 归档

  • 搜索

彻底理解内部类的使用(详细篇)

发表于 2021-08-23

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

前言

内部类相信大家都应该用过,但我也相信大家应该都只是很简单的使用。

所以今天,就来详细讲解内部类的使用,废话不多说,我们先赶紧来看吧。

在普通类中使用内部结构

先来看个示例:

Teacher类里面有个内部类Student:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class Teacher {

private String msg = "我是一名老师";//只能在类内部访问

public void say() {
Student s = new Student();//实例化内部类的对象
s.print();
}

//在Teacher类中的内部类
class Student {
public void print() {
System.out.println(Teacher.this.msg); //msg是Teacher类里面的属性
}
}

}

测试类:

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Teacher teacher = new Teacher();
teacher.say();
}
}

输出结果:

1
2
3
vbnet复制代码我是一名老师

Process finished with exit code 0

从整体的代码结构来讲,内部类的结构并不合理,所以内部类本身最大的缺陷在于破坏了程序的结构,但是破坏需要有目的的破坏,那么它也一定会有其优势,如果要想更好的观察出内部类的优势,就可以将内部类拿到外面来。我将上面的代码Student拿出来。

Teacher 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class Teacher {

private String msg = "我是一名老师";//只能在类内部访问

public void say() {
Student student = new Student(this);//实例化内部类的对象
student.print();
}

public String getMsg() {
return this.msg;
}
}

Student 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class Student {

private Teacher teacher;

public Student(Teacher teacher) {
this.teacher = teacher;
}

public void print() {
System.out.println(this.teacher.getMsg()); //msg是Teacher类里面的属性
}

}

测试:

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Teacher teacher = new Teacher();
teacher.say();
}
}

输出结果:

1
2
3
vbnet复制代码我是一名老师

Process finished with exit code 0

我们折腾了半天主要的目的就是为了让Student这个内部类可以访问Teacher这个类中的私有属性,如果不用内部类的时候整体代码会非常的麻烦,所以可以得出内部类的优点:轻松的访问外部类中的私有属性。

需要注意的是:内部类虽然可以方便的访问外部类中的私有成员或私有方法,同理**,外部类也可以轻松访问内部类中的私有成员或私有方法**。

示例如下:

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复制代码public class Teacher {

private String msg = "我是一名老师";//只能在类内部访问

public void say() {
Student student = new Student();//实例化内部类的对象
student.print();
System.out.println(student.info);
}

class Student {
private String info = "我是一名学生";

public void print(){
System.out.println(Teacher.this.msg);
}
}
}

public class Test {

public static void main(String[] args) {
Teacher teacher = new Teacher();
teacher.say();
}
}

输出结果:

1
2
3
4
java复制代码我是一名老师
我是一名学生

Process finished with exit code 0

使用了内部类之后,内部类与外部类之间的私有操作的访问就不再需要通过setter,getter以及其他的间接方式完成了,可以直接进行操作,但是需要注意的是,内部类本身也属于一个类,虽然在大部分情况下内部类往往是被外部类包裹的,但是外部依然可以产生内部类的实例化对象,而此时,内部类实例化对象的格式如下:

外部类.内部类 内部类对象 = new 外部类().new 内部类();

在内部类编译完成之后会自动形成一个Teacher$Student.class类文件,其中“$”这个符号换到程序中就变为“.”,所以内部类的全称:“外部类.内部类”。内部类与外部类之间可以直接进行私有成员的访问,这样一来内部类如果要是提供有实例化对象了,一定要先保证外部类实例化了。

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Teacher.Student student = new Teacher().new Student();
student.print();
}
}

如果此时Student类只允许Teacher类来使用,那么在这样的情况下就可以使用private进行私有定义。

这样,此时的Student类就无法再外部使用,即在test中的这条语句 Teacher.Student student = new Teacher().new Student()就失效。

在抽象类和接口中使用内部结构

在我们的java之中,类作为最基础的结构体实际上还有与之类似的抽象类或者是接口,抽象类和接口中都可以定义内部结构。

接口中定义内部接口

我们现在定义内部接口:

1
2
3
4
5
6
7
8
9
java复制代码public interface Study {

public void study(XiaoMing xiaoMing);

interface XiaoMing {
public String getName();
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class StudyImpl implements Study {

public void study(XiaoMing xiaoMing) {
System.out.println(xiaoMing.getName());
}

class XiaoHua implements XiaoMing {

public String getName() {
return "我叫小华";
}
}
}

测试:

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Study study = new StudyImpl();
study.study(((StudyImpl)study).new XiaoHua());
}
}

输出结果:

1
2
3
vbnet复制代码我叫小华

Process finished with exit code 0

接口中定义内部抽象类

下面我们继续观察一个内部抽象类,内部抽象类可以定义在普通类,抽象类,接口内部都可以。

1
2
3
4
5
6
7
8
9
java复制代码public interface Study {

public void study();

abstract class XiaoMing {
public abstract String getName();
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class StudyImpl implements Study {

public void study() {
XiaoMing xiaoMing = new XiaoHua();
System.out.println(xiaoMing.getName());
}

class XiaoHua extends XiaoMing {
public String getName(){
return "我叫小华";
}
}
}

测试:

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Study study = new StudyImpl();
study.study();
}
}

输出结果:

1
2
3
vbnet复制代码我叫小华

Process finished with exit code 0

用内部类实现外部接口

内部类还有一些更为有意思的结构,即:如果现在定义了一个接口,那么可以在内部利用类实现该接口,在JDK1.8之后,接口中追加了static方法可以不受到实例化对象的控制,现在就可以利用此特性来完成功能。

接口内部进行接口实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public interface Study {

public void study();

class XiaoMing implements Study {

public void study() {
System.out.println("我爱学习!!");
}
}

public static Study getInstance() {
return new XiaoMing();
}
}

测试:

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Study study = Study.getInstance();
study.study();
}
}

输出结果:

1
2
3
vbnet复制代码我爱学习!!

Process finished with exit code 0

从上面可以看到,内部类是非常灵活的结构,只要你的语法满足了,各种需求都可以帮你实现!

static 定义内部类

static定义内部类

如果说现在内部类上使用了static定义,那么这个内部类就变成了外部类,static定义的都是独立于类的结构,所以该类结构就相当于是一个独立的程序类了。需要注意,static定义的不管是类还是方法只能够访问static成员,所以static定义的内部类只能够访问外部内中的static属性和方法。

1
2
3
4
5
6
7
8
9
10
java复制代码public class Teacher {

private static final String msg = "我是一名老师";

static class Student {
public void print(){
System.out.println(Teacher.msg);
}
}
}

这个时候的Student类是一个独立类,如果此时要想实例化Student类对象,只需要根据“ 外部类.内部类 ”的结构实例化对象即可

格式如下 外部类.内部类 内部类对象 = new 外部类.内部类();

测试:

1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Teacher.Student student = new Teacher.Student();
student.print();
}
}

输出结果:

1
2
3
vbnet复制代码我是一名老师

Process finished with exit code 0

所以以后如果发现类名称上提供有.,首先应该立刻想到这是一个内部类的结构,如果可以直接进行实例化,则应该立刻认识到这是一个static定义的内部类,但是static定义内部类的形式来讲并不常用,static定义内部接口的形式最为常用。

static定义内部接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public interface StudyWarp {

static interface XiaoMing {
public String getToSchool();
}

static interface GOSchool {
public boolean hasCar();
}

public static void study(XiaoMing xiaoMing, GOSchool goSchool) {
if (goSchool.hasCar()) {
System.out.println(xiaoMing.getToSchool());
} else {
System.out.println("没车送小明上学。");
}
}

}
1
2
3
4
5
6
java复制代码public class DefaultStudy implements StudyWarp.XiaoMing{
@Override
public String getToSchool() {
return "我去上学!";
}
}
1
2
3
4
5
6
java复制代码public class DefaultSchool implements StudyWarp.GOSchool{
@Override
public boolean hasCar() {
return true;
}
}

测试:

1
2
3
4
5
6
java复制代码public class Test {

public static void main(String[] args) {
StudyWarp.study(new DefaultStudy(),new DefaultSchool());
}
}

输出结果:

1
2
3
java复制代码我去上学!

Process finished with exit code 0

方法中定义内部类

内部类可以在任意的结构中定义,这就包括了:类中、方法中、代码块中。但在方法中定义内部类的情况比较多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class Teacher {

private String msg = "我是一名老师";

public void say(long time){

class Student { //内部类
public void print(){
System.out.println(Teacher.this.msg);
System.out.println(time);
}
}

new Student().print();

}
}
1
2
3
4
5
6
java复制代码public class Test {

public static void main(String[] args) {
new Teacher().say(System.currentTimeMillis());
}
}

输出结果:

1
2
3
4
java复制代码我是一名老师
1629704407680

Process finished with exit code 0

此时在say方法内部提供有Student内部类的定义,并且发现内部类可以直接访问外部类中的私有属性也可以直接访问方法中的参数,但对于方法中的参数直接访问时从 JDK1.8 开始支持的。而在 JDK1.8 之前**,如果方法中定义的内部类要想访问方法中的参数则参数前必须追加final。**

之所以取消这样的限制是为了扩展函数式编程准备的。

匿名内部类

匿名类是一种简化的内部类的处理形式,其主要是在抽象类和接口的子类上使用的。

接口和抽象类是一样的。

1
2
3
4
5
java复制代码public interface Study {

public void study(String book);

}
1
2
3
4
5
6
java复制代码public class XiaoMing implements Study{
@Override
public void study(String book) {
System.out.println(book);
}
}
1
2
3
4
5
6
7
java复制代码public class Test {

public static void main(String[] args) {
Study study = new XiaoMing();
study.study("红楼梦");
}
}

如果说现在Study接口中的XiaoMing子类只使用唯一的一次,那么是否还有必要将其定义为单独的类?那么在这样的要求下就发现这个时候定义的子类是有些多余了,所以就可以利用内部类的形式来解决此问题。

1
2
3
4
5
java复制代码public interface Study {

public void study(String book);

}
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class Test {

public static void main(String[] args) {
Study study = new Study() {
@Override
public void study(String book) {
System.out.println(book);
}
};
study.study("红楼梦");
}
}

匿名内部类不一定要在抽象类或接口上,但只有在抽象类和接口上才有意义。有些时候为了更加方便的体现出匿名内部类的使用,往往可以利用静态方法做一个内部类的匿名内部类实现。

在接口中直接定义匿名内部类。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public interface Study {

public void study(String book);

public static Study getInstance(){
return new Study() {
@Override
public void study(String book) {
System.out.println(book);
}
};
}
}
1
2
3
4
5
6
java复制代码public class Test {

public static void main(String[] args) {
Study.getInstance().study("红楼梦");
}
}

与内部类相比匿名内部类只是一个没有名字的只能够使用一次的,并且结构固定的一个子类。

总结:

方法,类,抽象类,接口,代码块中都可以定义内部结构——类,抽象类,接口。

本文主要讲了如何在内部类中使用内部类和接口中使用内部类以及如何使用static修饰的内部类和如何在方法中使用内部类。看完这篇相信你对内部类的理解更加深入了。

结尾

我是一个正在被打击还在努力前进的码农。如果文章对你有帮助,记得点赞、关注哟,谢谢!

本文转载自: 掘金

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

趣头条反作弊技术演进与思考

发表于 2021-08-23

趣头条安全团队简介:负责趣头条所有业务线业务安全和基础安全,自主研发设备指纹、实时风控、风险识别等反作弊系统,以及WAF、漏洞平台、白盒扫描等基础安全平台,负责公司合规安全、网络安全、系统安全、数据安全和业务安全等。

趣头条App自发布以来,一直受到黑产和散户的不断攻击,反作弊在与黑产一次次对抗中,逐渐吸取经验教训,形成了符合自己业务特色的反作弊技术体系。本文将从实用性角度简要介绍一下趣头条反作弊的技术架构和SDK部分细节。

反作弊SDK

为什么要开发反作弊SDK?

第一,与业务解耦。很多公司的反作弊,是没有自己收集数据的,依赖业务数据来做分析,这样固然也能做反作弊,但业务数据量大,设备信息不全,用户行为繁多,数据格式无法统一,更有甚者,业务字段的变化可能完全不会通知反作弊团队,会导致策略失效。有了独立于业务的反作弊SDK后,不仅数据格式统一了,新增功能发版也更自由了,策略可控易维护。

第二,获取真实可信的设备环境。业务数据一般会直接调用系统接口取设备环境参数,但这些参数非常容易被Hook篡改,有大量专门的改机工具可以修改系统参数,所以要保护好系统参数,就要识别出这层修改,或绕过,或用其他办法来获取真实可信的设备环境数据,这就需要有独立的专业SDK。

第三,设备指纹。有了自己的SDK,就可以做唯一的设备识别了。在移动领域,不限于移动安全,设备指纹有着极其重要的地位,不仅用于反作弊,还用于业务数据统计和产品策略制定(如单一设备只能参加一次活动),还用于广告投放,渠道拉新等等。

很庆幸,我们一开始就走对了方向,坚持开发自己的反作弊SDK,经过数轮迭代,目前已经非常稳定,内部数十个App已经接入反作弊SDK,也就是说每出一个新App,首先需要接入的就是反作弊SDK。

在SDK防护方面,我们也做了相当多的工作,在保证兼容性的前提下,尽可能做到安全,提高被破解的门槛和风险。

首先,我们自定义了一套数据编码格式,即非JSON,也非Protobuf,兼顾了安全和编码效率,编码后的数据再经过压缩,变成更加紧凑的二进制。

其次,我们设计了自己的加密算法和签名算法,并用C实现,且做了混淆和加固。抓包拿到的报文,经过编码压缩加密后,是二进制,企图通过猜测碰撞的办法来破解加密算法,几乎是不可能的。

然后,核心参数,我们会在C层也获取一次,并和Java层相互校验,如果缺失C层参数或C层和Java层不匹配,那么就有理由怀疑用户使用了改机软件或非真实设备。

最后,我们会检测调试,调试状态下返回的报文和正常报文有差异。

当然SDK防护不限于此,总之,在多个点上安插我们的防护代码,加强SDK自身安全,提高门槛,并且做到风险可感知。

反作弊SDK完成了两项重要任务:

  1. 结合服务端算法生成设备指纹和tuid(下文有详细介绍),依靠SDK对客户端环境很强的感知能力,加上强大的服务端算法,设备指纹的可以达到很高的准确度。
  1. 上报客户端环境参数,我们采集的参数偏向于显性的,例如开机时间、音量、光感等等,是符合国家安全合规要求的,部分参数只有在用户同意的情况下,才会获取,而且单次回话中不会大量重复获取,依靠这些看似不起眼的参数,我们可以做很多策略来识别虚假设备,例如模拟器、越狱、参数聚集等等。

以模拟器为例,通过校验各种参数,我们开发了30多种识别模拟器的策略,严谨而准确。众所周知的,模拟器一般的CPU架构不是 ARM,而是 Intel 或 x86,通过这一特征,可以大幅缩小模拟器的识别范围,但有些手机CPU架构确实是x86,所以要结合其他参数来辅助判断。市面上大多模拟器,都有自己的一些特征,某些参数无法修改,或者一般用户不知道怎么修改,那么可以通过这些特征轻而易举地识别出这类模拟器,例如蓝叠模拟器的Wifi名字可能就叫BlueStacks。大多数模拟器,都有自己独特文件或进程特征,这些不易被篡改,识别准确度也很高,例如雷电模拟器进程中含有 com.android.emu.coreservice。还有些云真机默认的品牌和硬件参数,会暴露它的身份,如品牌为 Redfinger、CloudPhone 可能是红手指云手机、华为云手机,但这些参数可以轻易修改,这就需要其他策略来识别了。

设备指纹

唯一标记一台真实设备,在用户正常操作后,如重装App、重启设备、重装系统、备份还原等,能确保设备指纹不发生变化。注意,设备指纹不是银蛋,不是万能的,所以这里说的是真实设备、正常操作。对于虚假设备,我们只有策略或模型识别出假设备就可以了,不必保证设备指纹唯一。恶意用户异常操作也是一样,识别处理即可,无需在设备指纹算法上浪费太多精力,即便能保证唯一,意义也不大,回报率极低。当然,并不是说,只要用户稍微篡改一下设备参数,我们设备指纹就放弃抵抗了,这里有一个平衡,避免花大量时间和精力做强对抗,如果低成本能保证篡改后设备指纹依然唯一,那最好了,我们现在就能保证大多数情况下参数被篡改后设备指纹依然不变。

我们的设备指纹经过了多次算法尝试和技术的演进,每个版本都迭代了数十次。

第一版,单字段映射。

最初开始设备指纹的算法研究与实现时,Android 生态一片繁荣,设备参数如IMEI还基本可用,黑产也没有到疯狂的地步。我们的算法主要是以单字段映射为主,也就是ID Mapping。这个算法的好处是简单,容易理解,容易实现,遇到问题,也能轻易地排查找到原因。数据全部放在Redis中,做了高可用,并实时备份,以防Redis意外,数据不会丢,并能快速恢复。开始上线时,效果非常好,准确率高。期间,做过一次大迁移,通过数据双写,服务从一个云迁移到另一个云,数据没有一条丢失和错误,这得益于合理的方案和周密的计划。但这套系统用了不长时间,很快问题出现了。业务爆发式的增长,我们的设备指纹服务,设备量很快过亿了,由于ID类型一直在不停地增加,加一个,就是加一亿的数据,最终Redis Key 达到十多亿,一再扩容,但成本也急剧上升,再估算一下未来的业务增长趋势,成本将无法控制。同时另一个问题,也显现出来,随着设备的增多,单字段的冲突问题逐渐暴露出来了,多个设备被识别成一个,这也是完全不能接受的。

第二版,多字段映射。

在第一版中,冲突的问题,是绕不过的,于是,我们又重新思考了,最终决定,采用多字段映射,在解决冲突的前提下,依然能够保持简单。但Redis成本问题,已经无法胜任了。调研了一些数据库,没有找到合适的数据库,在各种权衡下,还是使用了MySQL,一张表建了10多个索引,就这样,在已知有风险的情况下,依然跑了起来。这个版本使用了很长时间,系统稳定,设备指纹准确率也提升了很多,在快速增长的业务需求下,依然保持了较高的可信度,误差很小。

但该来的问题还是来了,在一个安静清明节早上凌晨4点,系统报警了,MySQL CPU 过高。难以想象在系统低峰期,CPU 居然过高?后证实下来,是云服务共享导致的,立即切换了独享版。

平静了两个月后,系统再次报警了,这次是周六早上,排查下来,是DB超时错误。日志中的 SQL 重跑一遍后,很快发现了问题,居然有子查询返回上万条数据,是意料之外的。于是立即做了降级处理,最多返回100条。问题得到暂时的缓解。深入排查后,发现是设备参数的默认值导致的,上万设备某个参数都是一样的,于是对这些默认值做了特别处理后,系统又恢复正常。

但从这次事故中,我们还是感觉到MySQL的风险,数据量大,索引即将超过限制,没有扩展的余地,MySQL 毕竟是关系型数据库,不适合 KV 场景。于是,我们又花了大量时间,重新调研适合我们场景的数据库,尤其是 KV。在经过数月的评测研讨,最终有了结论,尝试一个新的数据库 Aerospike。

经过了漫长的数据迁移和对比实验,Aerospike终于被证实是可靠的而且适合我们场景的数据库。至此,DB问题终于被解决了。

第三版,基于自主优化的模型算法。

Android 生态接二连三地发生重大变化,尤其是IMEI 被禁用,OAID作为替代方案,还有国家安全合规要求,不能过多过早获取用户设备信息。虽然我们并不依赖某些设备参数,但隐私政策收紧的趋势已经很明显,必须提前做好准备。于是我们又开始计划新的设备指纹算法,形式不同往日,当前挑战比以往更大。复杂的问题允许相对复杂的解决方案,我们新的算法相对前面版本已经不再简单了,而是一个可以自主优化的算法,可以随时调整模型,能让后来生成的设备指纹越来越准。经过数月的对比实验,不仅证明了新算法的可行性,而且证明了新算法有天然的优势,准确率高于前一版本。

设备指纹是个细致活,通过一次次的优化,一个个的细节问题被发现被解决,踩过的坑都成为了我们光辉的历史,指导我们一步步把设备指纹做得更准确更稳定。

tuid

曾经,tuid是个极富争议的话题,外部环境不可控,并非所有人都能理解,所以不同的部门认知不同,对这个ID有不同的要求,要满足所有的要求,是不可能的,需要在平衡各种需求的情况下,尽力提升ID的准确性实时性。

tuid 是什么?tuid是 服务端设备指纹(即上文讲到的设备指纹)和客户端临时指纹的组合提取。新设备或App重装后,服务端设备指纹需要一次网络返回,在网络返回前,业务数据已经开始上报了,这时就需要一个ID来对应到这个设备,并且能和服务端设备指纹关联上。在设计设备指纹时,我们就预先考虑到这个情况,所以让客户端临时指纹和服务端设备指纹有很强的关系。这样,经过一定的算法和匹配,就能够从两个指纹中提取出一个共同的ID来,这就是 tuid。

首次提出tuid,是在AB实验中,由于IMEI冲突、改码、用户不给权限等等,造成通过IMEI等设备参数无法进行实验分流,得出的数据非常不置信。数据中心找到我们,希望能够提供一个相对稳定的ID用于实验平台。由于对实时性要求较高,单纯服务端设备指纹也满足不了要求,于是我们提出可以试一下客户端临时指纹+服务端设备指纹的方案,从两者当中提取出共同的 tuid,作为实验分流ID。实验平台经过尝试后,发现效果非常好。自此便开启了 tuid 的时代,但也只是单一平台小规模运用。

然而,IMEI等设备参数不可靠的问题,在业务线越来越严重,对比tuid或其他平台,108万的新增,使用IMEI作为口径,可以统计出127万新增,误差接近20%,同时各业务线不同部门数据更是难以对齐。在 ONE ID ONE DATA ONE SERVICE 的号召下,各部门尤其是数仓,迫切需要一个相对靠谱的ID来打通并对齐底层数据。

在对比了各个ID后,包括 QID、Android ID、UUID、tuid、IMEI、其他第三方ID,发现并不存在一个理想的ID,能满足所有场景,尤其是Android 环境不断变化的情形下,无法预知某个ID在未来几年的表现。但可以确定的是,在明确App打开前几秒,ID 可能不准的前提下,最接近理想的 ID 是 tuid,于是会议决定使用 tuid 作为 打通并对齐底层数据 的唯一ID。于是,tuid正式登上舞台。全业务线,所有数据体系大规模改造,全部使用 tuid。

随着数仓系统的改造,各种数据也逐渐对齐,指标也趋于稳定,IMEI 混乱的时代终于可以结束了,有了 tuid,数据第一次被算清楚了,这使所有部门看到了数据的准确,看到了数据的整齐,决策也有了信心。

但事情并没有那么顺利,在由谁计算新增的问题上,反作弊和数据中心的意见产生了分歧。反作弊提供了的 tuid 方案,而且有自己的设备指纹系统,已经实时标记了新增。但数仓的要求是,新增必须走数据中心来计算。走反作弊,优点是方案和实现统一在反作弊形成闭环,有现成的系统,缺点是有少量设备可能只在业务线上报,不在反作弊库中,新增会少。数据中心计算新增,优点是数据中心自己负责重要数据的产生和计算,自己负责高可用,不依赖其他系统,缺点是设备参数可能发生变化,在App打开前几秒,客户端临时指纹有一定概率和服务端设备指纹不一致,导致生成两个tuid,计算两次新增,也就是说实时数据产生了假新增,新增会多,但离线可以排除,日活影响不大。

各方沟通下来,最终还是确定了数据中心来计算新增。这就需要一套方案来消除或降低假新增比例。离线能够识别假新增,是因为反作弊同时上报了客户端临时指纹和服务端设备指纹,可以做映射。所以我们上线了一套tuid转换服务,解决实时数据有假新增的问题。这个方案成本很大,需要所有使用tuid的服务都做一次tuid转换的改造,部分业务线在网关层做了转换,后面的微服务就不需要转换了。这个转换过程99.9%是本地的,只有不到0.1%需要远程。假新增就在这0.1%中,转换后,实时的假新增比例控制在1-2%,这个值是稳定的,而且离线可以排除干净。

tuid改造完成之后,实时指标也越来越准确越来越稳定。

但自Android 8以后,Android 系统接二连三地发生了重大变化。首先 Android ID 不再是唯一的,同一设备对不同的App会返回不同的 Android ID。然后 Mac 地址随机,接着禁止获取序列号等等,其他参数也受到系统管制和权限的限制。好在我们的设备指纹系统不强依赖于这些参数,所以基本没受到任何影响。客户端临时指纹的稳定性虽受到挑战,但得益于tuid转换服务,tuid的依然能够保持相对稳定。

然而,随后而来的隐私合规和国家出台的一系列个人信息保护政策,对我们tuid冲击很大。我们获取的环境参数都是在合规范围内的,也不会获取敏感的用户个人信息,例如电话本、短信等等,所以这些要求,对我们没有影响。影响大的是,要在用户同意隐私协议之后才能获取环境参数,这对我们一向主张的实时性要求相冲突。我们要求反作弊SDK要尽早初始化,尽快拿到服务端设备指纹。但隐私协议的规定让我们不得不延迟初始化,这样带来了更多的设备没有拿到服务端设备指纹,而只能拿到客户端临时指纹,所以造成了假新增暴涨,最高达到18%,这是不可接受的。

必须有一套方案能够解决Android系统变化问题和反作弊SDK初始化较晚的问题,而且要低成本。理想情况是,客户端有个准确的不需要权限可以随用随取的不会变化的设备ID。但这只能个空想,过去不可能,现在国家安全的要求和Android系统的发展趋势都不允许有这样的ID来跟踪用户行为。

其实通过日常数据监控,我们已经发现客户端临时的设备指纹稳定性在缓慢变差,需要增强,tuid转换服务过于简单,数据不丰富。再进一步分析数据,发现假新增可以由其他环境参数,通过模型来修正,可修正比例高达 80%以上,再加上多种策略,大幅降低假新增比例,是完全可能,而且是确定可实现的。成本呢,我们已有的tuid转换服务,为我们搭好了架构,铺好了路,只要稍做调整即可。于是客户端临时指纹增强版,加上服务端tuid转换服务优化,方案定下来了。由于改造不太,实现很快落地了,效果和预估的一样,指标变化非常显著,加上网关层全面推进tuid转换,假新增从18%重回 1-2%。

​

回想一下,当初确定tuid转换方案的时候,确实遇到不少阻力,好在有领导层的支持,有大家的坚持,最终全面铺开了。现在看来,这个架构抗风险性很强。如果没有这个tuid转换服务,很难想象,隐私合规和Android系统的变化带来的挑战,我们如何才能应对。这个问题即便放到现在,也没有一套方案能够做得更好,我们没有,市面上也没有,甚至很少有公司考虑过实现客户端设备指纹的事情,虽然这是刚需。

tuid 的困难在于外部环境不可控。从小的方面讲,设备的碎片化可能导致任何意想不到的问题,从大的方面讲,国家政策,Android 系统重大更新,都是无法预测的。

tuid转换服务,就在客户端和服务端做了一个缓冲的桥梁,缩小甚至消除外界变化带来巨大影响。后续大环境发生变化,我们依然可以通过这个缓冲调整模型来应对。

上述从三方面介绍了反作弊SDK相关的技术和变革,反作弊SDK解决了反作弊遇到的挑战,同时也满足了业务苛刻的需求。对比业界优秀的设备指纹服务,我们的设备指纹达到了极高的准确率,双方的准确率均接近99.99%,在个别设备识别上各有千秋。独创的tuid方案,在业界首次解决了实时数据统计分析无可靠口径的难题。

趣头条反作弊从零开始,把一个个服务建立并完善,在真实的场景中磨炼,得到反馈,总结经验,吸取教训,沉淀出了一套完整的闭环的反作弊体系,已经成为中台核心组成部分。黑产技术在不断更新,反作弊技术也需要持续创新,并且要提前防御,尽可能避免与黑产持续强对抗。

E N D

目前,已有外部公司与我们合作,帮助他们解决黑产问题和业务上的数据问题,他们能够享受与我们内部业务无差别的服务,使用我们完整的反作弊技术,例如实时风控、风险识别、算法模型等等。如果您或您的公司在业务中遇到了困难或黑产攻击,可以联系我们, 我们愿意提供技术交流和服务试用。联系邮箱 ifsall@qutoutiao.net。

本文转载自: 掘金

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

国内酒店:通过架构改进,我们计划优化掉300+台服务器

发表于 2021-08-23

image.png

郑吉敏,2019 年 8 月加入国内酒店报价中心团队,主要负责报价相关系统开发及架构优化。对高并发高可用有浓厚兴趣,有日订单千万分布式系统高可用建设经验。喜欢钻研算法,acmicpc 程序设计大赛两次进入亚洲区预选赛。曾在 Qunar 首届 Hackathon 大赛中获得一等奖。


一、背景

1、线上问题回顾

2019年8月某天,那时我刚入职还不到一个月,遇到一个特殊的线上问题:某报价应用个别机器连续FGC不停(准确的说是FGC几乎没任何效果),这些机器重启后不久继续开始不停的FGC。当时特意留了当时现场的jstat数据截图,这也是我头一次遇到连重启应用都解决不了FGC的场景。最终,通过删除一些无用字段等手段让FGC问题得到缓解。

之后,我认真的梳理了这个应用的核心接口逻辑及总体架构设计,最后对这个“有状态”架构设计(准确的说,是分布式的数据懒加载架构带来的压力及内存不均衡)有了清晰的认识,也萌发了对这个设计进行“大手术”的念头,本文的重点也是对这种架构进行的优化。

图片

2、涉及的核心接口说明

首先做一下相关名词解释:酒店排序算法通常分为全局排序(粗排)和部分酒店二次处理(精算),全局排序阶段涉及酒店量大,而且特征影响因素很多,报价的具体精确值对排序结果影响不大,之后部分二次处理是因为需要选择部分做页面展示,需要对展现的那些少量酒店按身份、参与的活动等精确计算。

接下来具体介绍一下这个应用提供的核心接口:Sort接口。这个接口主要完成的就是对用户搜索酒店列表时的“粗排”,具体来说,是根据城市等参数返回满足条件的酒店排序,同时返回最低价等一些报价信息。这个“粗排”完成之后,上游的搜索排序系统结合一些特征因素选择出需要展示的部分酒店,继续调用其他应用的Render接口完成对指定酒店的“精算”,这时会得到实际的酒店最低价及参加的活动、优惠等详细报价信息。总结一下,Sort接口完成的是酒店的筛选及基础的排序,最终每个酒店的报价详细信息是来自于其他应用的Render接口完成。

我们这里实际要做的就是对Sort接口的优化。

图片

3、“有状态”架构说明

通过下图可以看出,Sort接口在Nginx(之后切换到OpenResty)层有负责的路由逻辑,在路由层根据多个请求参数借助一致性hash等路由策略将整个应用拆分了将近90个集群!不同的集群的内存里缓存了不同的数据。这一切,标志着Sort接口的设计是个复杂的“有状态”架构设计。

暂时先不说“有状态”架构设计的优劣,单独为每个集群准备1台服务器,线上就需要将近90台服务器,而实际中我们需要保证每个小集群都自成一个独立完整的集群,那么每个集群至少需要2台服务器(否则发布时某个集群就可能没机器在线了),这样线上就至少需要将近180台服务器了。再加上一些对北上广深等热门城市需要重点保证高可用,每个热门集群会冗余部署10几台甚至20几台服务器,还要考虑跨机房部署等各种情况,最终这个应用实际在线服务器将近340台。

Sort接口架构设计额外需要补充的是,当请求进到指定服务器上进行处理时,如果内存里没有数据命中,同步流程会直接返回无报价,同时会异步抓取相关报价,借助一条链路的“有状态”设计(比如发送和消费的消息带ip地址),最终相关的报价会写到指定的服务器内存里。这样下次有相同的请求进来时就可以命中内存了,这种设计带来的是每个请求都会牺牲第一个用户的体验。

( 说明:热门日期指的是出发日期、到达日期相对今天都在3天以内,即T+0、T+1、T+2、T+3 ,比如 beijing03 走的是专门的热点集群,而beijing04 由于日期不热门,走的是普通集群 )。

图片

二、调整前架构利弊分析

接下来,具体看一下当前“有状态”架构结合现有业务场景表现出来的优缺点。

1、优点

1)绝大部分请求可以精确命中内存,数据处理极快;

2)热门城市+热门日期对应的集群是稳定的,也承担了绝大部分核心的请求压力;

3)非热点集群如果出问题,影响较小,请求量也小。前面的问题回顾里,实际影响是很多非热门的城市的请求,虽然个别集群在一直FGC,但一个非热门集群占比很低,对总体影响并不大。

2、缺点

1)采用有状态设计,增大了分布式系统的复杂性,运维成本较高,扩展性差一些,不能轻易做横向扩展;

2)路由层很难调整,且调整的风险较高。如果调整,还需要考虑内存及redis里已有数据的处理,并且出现短时间的数据命中率变低;

3)非热门集群请求不均衡(单个集群内是均衡的),这是一致性hash策略带来的,最终导致不同集群处理的请求量差别很大;

4)非热点集群间使用内存差别较大。通常一级节点为”城市FromTo”,主要看城市下酒店的数量及酒店报价情况,不同城市差别巨大,因此会出现有些服务器内存紧张、有的服务器内存使用很少的情况;

5)为了控制缓存的数据量,非热点集群一级节点有上限,这就容易出现一些重要的请求没有被缓存。比如上限的100个节点满了,之后beijing04这种请求就无法放入内存,而之前有xxx04可能一直占着内存节点却用不上;

6)热门日期不可变,而实际中热门日期可能会有短时间的调整,比如国庆前 beijing04、beijing05可能变为热门日期;

7)无法确定一个非热门请求具体落在哪个集群哪个节点上,因为实际日期一变算出的绝对From和绝对To的值就发生变化了;

8)同一个集群下不同节点缓存的内存数据也是不同的,导致很难精确的知道一个请求具体会落在哪台机器上;

9)不利于压测。调权不方便,也很难控制流量单独打到指定集群,相同请求不同时间可能会打到不同的机器上。

三、去“有状态”架构规划及过程

1、核心矛盾确定与分析

核心矛盾:接口响应时间与数据存储之间的矛盾。

具体说一下,从用户体验出发,接口响应时间要越短越好。接口响应时间要短,数据的存储就非常关键(获取数据时间:内存 > 分布式缓存 > DB),这里要保证接口尽可能快,就会考虑使用内存。但是放在内存的话,就会出现数据量级太大,普通虚机可能存储不下的情况,就会引出目前”有状态“架构的方式进行存储。

如果能通过优化让单台机器完全缓存下来相关报价数据,那么“有状态”架构就可以理论上废弃掉了,这时需要考虑机器启动时数据拉取及启动后的数据变更处理,也会涉及到数据压缩存储、实际报价落地(本质上说是单日价落地)、实际报价变更的处理,这时就既能满足接口响应时间短的需求,又能保证数据存储没问题。

2、去“有状态”架构方案调研

本质上我们是期望既要保证接口响应时间尽量短,又要保证数据存储合理。我们专门做了相关方案的调研,以及去其他同行业公司去做经验交流,最终我们明确了一些结论和注意事项:

1)做酒店搜索排序时,主要依靠专门的搜索排序服务完成,而不是业务团队来完成,会借助搜索引擎来完成搜索和排序;

2)数据做好压缩存储,实体机是可以存放全量酒店维度的报价数据的;

3)如果使用实体机做全量数据缓存,需要处理好启动时全量数据的拉取,以及启动之后增量变更数据的同步;

4)需要考虑大内存机器FGC时间长的问题;

5)国内酒店报价都是按天给出的,多日价很少,可以忽略(这一点对数据存储特别关键)。

下图是一个典型的同行业公司的报价业务整体架构设计:

1)存储服务:接受来自portal管理台人工录入和同步ota系统相关数据,将其保存到DB中,并广播变更信息;

2)搜索服务:消费相关消息,写入nosql中,提供列表页粗排;

3)查询服务:消费相关消息,写入redis和内存中,提供列表页精算及详情页报价。

图片

3、去“有状态”架构方案确定

在做了大量的调研和探讨后,我们制定了最终的架构调整方案。方案的核心是去掉Sort接口,【粗排】相关字段通过拉取及消息变更放到上游的搜索排序系统里,需要准确的字段借助Render接口【精算】来保证。这样,Sort接口的“有状态”架构设计及复杂的路由层逻辑就都可以去掉了。

最终的架构形态如下图所示:

图片

为了完成上图中橙色部分的数据处理和逻辑调整,我们复用了之前酒店分销业务已经落地的报价数据,通过增加处理变价消息等手段,将相关数据存储到相关的Redis里去适配本次架构调整,并且将报价数据以消息的方式同步到上游搜索排序系统。搜索排序相关的服务器在启动时主动拉取相关数据到内存中,并且处理相关的变化消息来保证内存中数据是最新的。这里实际调整很大,涉及的逻辑也比较复杂,具体可参考下图:

图片

4、实际落地规划与风险控制

1)报价侧调整底层存储,接收分销业务已有的单日价变更消息(本次新增),存储相关数据到Redis中;

2)报价侧获取单日价的变化,去重后通知给搜索排序系统;

3)搜索排序系统处理通知,把数据放入内存(这是在确认了实际报价数据能在内存中全放下才确定放到大内存里的);

4)搜索排序系统将原Sort请求调整为查本地内存,不再调用Sort接口,逐渐放量验证,关注数据的覆盖程度、准确性、整个流程涉及应用的性能;

5)不断的优化请求处理,直至【粗排】完全查搜索排序系统本地内存;

6)原有Sort接口在最终全部切换前保持不动,作为实际切换出问题时的整体兜底策略;

7)搜索排序系统完成【粗排】全量查本地内存数据后,原Sort接口走下线流程。

四、预期收益

本次调整完成之后,Sort接口移除,路由层的配置移除,原有Sort接口所在应用部署的将近340台机器几乎全部下线回收(可能会留2-4台处理剩余的非核心请求,QPS很低),而上游的搜索排序系统由于本身内存放得下,不需要额外增加机器,同时也能更合理的使用大内存服务器的内存优势。当然,这些调整会增加对分布式缓存Redis、消息队列的使用,这部分资源成本的消耗可在Sort接口下线后,通过回收之前链路占用的资源来抵消。

总结一下:

1、酒店搜索排序服务可用性增强:去掉了对Sort接口的依赖;

2、酒店搜索排序流程总体时间缩短,用户体验得到提升:使用内存已有数据代替并发请求Sort接口获取数据;

3、节省Java应用300+台线上虚拟机:本质上是Sort接口占用的机器几乎可以全部节省下来了;

4、酒店整体架构趋于完善、统一,系统边界清晰:搜索排序团队负责筛选出酒店,报价业务团队则负责完成对实际酒店报价的精算;

5、开发人员日常运维成本降低:减少一个重型P1系统的日常运维,查问题效率可以得到提升;

6、OPS运维压力减轻:Sort接口路由层复杂的配置可以直接干掉。

五、未来规划

去“有状态”架构的事情我们还会持续去做,Sort接口的“有状态”架构设计被优化掉以后,配合完成“有状态”架构的还有一个完整的回路,之后可以继续沿着数据写入方向,倒序将整个回路里的“有状态”设计优化掉。这些整体上也符合“降成本”的方向。

除了去“有状态”的优化方向外,我们还会继续考虑对整体架构进行合理化和简单化。报价业务相对复杂,只有架构做简单和合理了,才能在实际中完成我们期望的“工作提效”和“降成本”。

END.png

本文转载自: 掘金

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

如何设计可靠的灰度方案 一 灰度的基本概念 二 灰度设计要解

发表于 2021-08-23

简介: 一个较大的业务或系统改动,往往会影响整个产品的用户体验或操作流程。为了控制影响面,可以选取一批特定用户、流程、单据等,只允许这一部分用户或数据按照变更后的新逻辑在系统中流转,而另一部分用户仍然执行变更前的老逻辑。这一步是线上系统灰度方案的起点。

作者 | 既同

来源 | 阿里技术公众号

一 灰度的基本概念

1 一个典型的灰度方案

一个较大的业务或系统改动,往往会影响整个产品的用户体验或操作流程。为了控制影响面,可以选取一批特定用户、流程、单据等,只允许这一部分用户或数据按照变更后的新逻辑在系统中流转,而另一部分用户仍然执行变更前的老逻辑。这一步是线上系统灰度方案的起点。

将用户按照特定规则分隔为两类之后,我们主要需要关注命中灰度的这部分用户,是否按照预期执行了新逻辑、产生了符合预期的数据,以及系统整体的变化等。此阶段即灰度观察阶段,线上验证工作也是其中的关键步骤。

随着系统中使用新逻辑的用户、订单等数据的逐步累计,即可证明新系统的正确性、有效性,那么更多的用户就应当被迁移进新逻辑中,这一阶段一般称作灰度推进。灰度推进有时是小流量验证后立即切全量的,也有需要逐步放量的,这需要结合实际业务&系统能力做出决定。

最终,全部用户被纳入到新逻辑的范围内,此时需要决定是否将灰度逻辑本身和系统中的老业务逻辑同步下线,全部用户仅可以使用新逻辑,此时即灰度完成。也有由于历史数据原因,长期无法完成全量灰度切换的,此时业务系统中将会长期驻留两套逻辑。

2 灰度在解决什么问题

一个变更如果在发布后立即全量上线,那么如果出现系统、逻辑、数据等问题,将会是灾难性的,比如全部用户无法创建新订单、全部新订单出现脏数据等,甚至有可能会影响到变更前的数据。

灰度过程就是在规避变更过程中这个最大的风险:全局影响。通过减小影响范围,再配合灰度线上验证、监控报警等手段,将出现问题时影响面,控制在有限的范围内,如减少订正的数据量或降低资损金额等。

安全生产规则中所谓的“无灰度,不发布”就是这个思想,通过灰度尽可能的减少问题的影响面。如果通过灰度过程发现一个线上问题,那么去掉灰度的保护,可能就会产生一个严重的故障。

3 灰度会带来什么风险

灰度方案可以规避全局性的影响,但是会不会带来其他的风险呢?答案是肯定的,工程中没有一劳永逸的银弹。

首先是如何发现灰度过程中的问题

这与上线过程中的监控报警有一定的相似性,二者主要都是依赖日志&监控&报警规则的建设和配置;但二者又存在一定的差异,如报警阈值如何配置才能有效发现小流量异常?灰度名单外的老逻辑会不会触发新逻辑的监控报警?灰度系统影响的上下游是否也有对应的灰度监控?这些问题都可能影响灰度问题能否被发现与发现问题的时效性。

此外,对灰度系统要重点关注资损风险。资损字段在上线前一定要做好核对的保障,或者至少应当在灰度开始阶段之前完成,尤其是对新变更引入或影响的资损字段,要做到全覆盖,“无核对不上线”。

灰度过程中还可以协同客户、运营、产品等多条线的同学做好布防,及时感知处理相关舆情,使用非技术手段作为问题发现的兜底与补充。

其次,如何控制灰度中问题的影响面

灰度过程中产生的灰度数据,不能侵入非灰度数据,反之亦然,要确保二者的充分隔离。

但是灰度系统需要与上下游联动,灰度本身也需要推进,一旦遇到问题,还需要进行灰度停止、灰度回退等更复杂的操作,因此灰度整体是一个动态的过程,而在整个动态过程中,需要严格保持灰度数据&非灰度数据的隔离,否则将会导致问题影响面扩大化,危及整个系统,甚至发生严重故障。

这里尤其需要注意的是灰度停止与灰度回退的复杂性:如果灰度停止手段不能生效,那么问题影响就无法得到有效控制;灰度回退则需要涉及阻断灰度流程、修改已有灰度数据、修复错误数据等,一般来说是整套灰度方案中最复杂的部分。

最后,发生问题时的处理也会比较复杂

生产系统往往没有太多的资源或条件进行AB-test,灰度与非灰度数据都是真实的业务数据,一旦出现问题,并不能通过删除灰度数据或脏数据的方式解决问题,一般需要进行数据订正,或发布新的变更进行修复。数据订正的数量、订正数据的正确性、如何甄别灰度用户、如何保证新变更的正确性、如何保证新变更可以有效修复问题数据等,都是恢复过程中的难点工作与潜在风险。

本章结语:

复杂的灰度方案会引入各种各样问题与风险,整个系统的复杂度也将成倍的增加,对灰度的质量保障方案也会同时变得更为复杂。那么如何有效的控制这些风险,同时高质量的达成项目目标呢?我们常说,好质量不是测出来的,对于复杂的灰度系统来说,这句话同样适用。一个高质量的灰度方案,不仅需要完善的测试,更要依赖于良好的设计。保障安全生产和达成项目目标二者绝不是矛盾的,只要灰度方案设计得当,鱼与熊掌可兼得之。

二 灰度设计要解决的基本问题

1 灰度维度的选取

生产系统中常见的灰度的规则,有用户id尾号、业务单据id尾号、白名单、黑名单、时间戳等。

白名单常用于线上测试,如使用测试账号等进行单独的验证。这种方式不适合单独使用,因为无法快速扩大灰度范围,但是推荐与其他方式联合使用,增加灰度过程的灵活性。

黑名单则是一种兜底手段,可以对特殊用户(如数据量特别大用户、重点客户等)进行屏蔽,减少或避免其受到灰度的影响,尤其是在灰度过程出现问题时,直接阻断其进入系统中的问题逻辑。

采用用户id尾号或业务单据id尾号作为灰度key,是更常见的灰度区分方式。但如何选取这类灰度key,需要注意几个要点。

第一,选取的key应当是均匀分布或近似均匀分布的,如集团的havanaId等,否则全量用户无法分批分散的命中新逻辑,灰度的逐步放量的能力就失去了作用,极端地,整个灰度能力会退化为布尔化的全局开关。

这里容易犯的错误并不是使用了全部相同的灰度key,而是误认为某个id是均匀分布的。举例来说,某单元化应用中如果使用用户id的后四位作为灰度key,那么很可能会出问题,因为用户id已经是用于区分单元化的标记了。常见的id的生成本身是随机的,但触达业务系统时,可能已经带有某种特定的规律了,因此需要对此类情况做好识别与防范。

第二,计算key的逻辑需要尽量简化

系统中使用灰度key来判别走新逻辑还是旧逻辑,这个条件判断一般会在系统中反复出现、多次执行,此时如果设计特别复杂计算方式,则会给系统带来额外的开销。除此之外,简化key的计算逻辑也会带来业务语义上的简化,便于整个业务链上的技术同学与非技术同学快速理解,也便于遇到问题时快速定位与排查,更有利于系统的长期维护。

第三,要结合业务实际选取

如果选取一个当次变更新增的业务字段作为灰度key,那么上下游系统是否需要做同步改造?离线数据&报表是否需要配合改造?如果选取一个对下游业务未记录的或无意义的字段呢?这些都是通过合理设计可以节省的改造成本。

因此在选取灰度key时,需要选取上下游业务已有的、通用的、具有业务意义的字段。

2 简化灰度逻辑

灰度逻辑仅仅是将一个用户或单据非此即彼的区分开,因此灰度逻辑不仅没有必要做的太过复杂,而且还应当尽量简化,如果业务上有条件,最好能用一个字段或一个变量搞定。

首先,有利于完成灰度进度的调整,如灰度推进,灰度暂停等,可以通过单变量的调整快速完成,否则一次性调整几个灰度变量,会出现灰度推进情况不符合预期、灰度覆盖不全,灰度数据不一致等复杂问题。比如同时调整用户id覆盖范围与订单创建时间,则可能导致一部分用户被跳过,也可能导致调整后的灰度范围远超预期等问题。其实这类问题在实际生产中是最常见的,回想一下,每次在灰度推进或灰度暂停等进度调整时,是不是都需要多人共同监督灰度脚本,反复确认发布内容?甚至在加入了如此重的流程之后,仍然不能达到百分百的无问题。

其次,开始灰度后,灰度数据往往错综复杂,如果需要多个条件协同判断,对问题定位则是不利因素,甚至可能会导致误判。还用上面的用户id+时间戳的例子来说,原本是灰度逻辑出错时产生的数据,可能被误判成由于时间未到而走旧逻辑产生的数据,这种复杂性导致的误判将会严重影响线上问题的止血与处理效率。

最后,对可灰度的用户或单据,应当宽进严出,适当提升灰度准入的门槛,这样做有利于将大部分数据快速的排除到灰度范围之外。因为总体而言,当我们决定采用灰度方案去推动变更时,我们总是抱着对系统悲观的态度,防止潜在的问题快速扩大化。因此在初始阶段让尽可能少的数据走到新逻辑,可以给我们留出时间做人工数据校验、监控报警有效性校验、核对有效性校验等等工作,防止第一波灰度用户出问题时,直接演变成大问题。那样的话,就完全失去了做灰度的意义。

这里还要做一个简短的解释,减少灰度变量和灰度命中宽进严出二者并不矛盾,前者一般是动态的、配置在开关内供应用读取的,后者一般是静态写在代码中的固定条件。举例来说,某一个变更使用用户id作为灰度变量,但初期应当设置仅对某等级以上的用户开放的门槛。

3 灰度数据如何初始化

灰度最好是可以从0启动的,就是说无需事先通过数据订正或批量触发的方式修改初始数据,而是通过某个真实的业务请求来触发,比如用户下单等。这里常见的做法是,当业务请求中的数据命中灰度之后,在创建对应的DB记录时,打上特殊的标记,用以标识灰度命中。如果有必要的话,还可以单独建立新的表,在DB中写入一条新记录就代表相关的用户或单据命中灰度。

这种方式的优点就是0启动,无需前置数据准备流程,但问题是整体的灰度进展可能会变慢。因为在上线前产生的部分部分线上数据已经被确定为仅能走旧逻辑,想要进行全量灰度后的灰度逻辑下线,一般来说,只能等待业务数据自然关闭。

举一个简化的例子,灰度启动前已经付款的订单走旧逻辑,如果不对这部分订单数据做处理,那么只能等待这部分订单全部确认收货,才能对灰度逻辑和旧逻辑进行整体下线。而在实际的生产系统,还要考虑退款、计费等等相关流程,所以等待的周期只会变得更长。

但有些灰度方案并不能简单的通过请求中携带的数据进行灰度初始化,还需要对全量的用户数据做一次初始化。比如将线上A系统中的数据,按一定规则导入本次变更涉及的B系统中,作为灰度过程的数据准备。这样做的好处有两个。

第一是可以在一些场景中简化灰度门槛的判断,即可认定所有的数据全部符合某一个前提条件,节约一次判断。而且这次查询一般会是一个查库操作,而使用全量业务数据去查库,常常会出现DB性能问题,甚至会出现由于灰度数据的分布问题导致分布式DB出现单库单表的热点,这里的DB问题不做深入。总之这个方案可以有效减轻甚至规避此类问题。

第二就是在业务上可以加速整体的灰度进度,缩短从灰度开始到全量的周期,有时出于业务的考量,我们可能不得不选择这个方案。

但这样做的缺点也是明显的。举例来说,比如数据初始化的方案是从A表导入B表,那么首先需要对数据迁移的逻辑进行经过额外的验证工作;之后进行迁移数据时也需要占用一定的项目周期;还要在设计中考虑迁移数据过程AB系统的数据一致性如何保障,比如迁移数据的过程中,A系统有产生了新的业务数据,要迁移吗?还是迁移时要对A表的部分记录加锁?或者甚至停掉A表对应的服务?真的需要停服务的话,那这也太不互联网了。

4 灰度过程中保持数据一致性

前文描述了灰度初始阶段的问题,但是灰度过程往往会从前一个业务步骤开始,随后才会影响下一个业务步骤。举例来说,同一个用户在t时刻命中了灰度规则,并在写表时打标命中灰度;而在之后的t+1时刻,发生了一个需要更新表记录的操作,但由于灰度回退或其他原因,导致没有命中灰度规则,这时要怎么判定?

这类问题其实就是灰度数据一致性的问题,也是灰度设计中最核心的问题。

原则1:以已有的灰度命中数据为准

在很多业务场景下,前一步写表后一步更新的操作是非常常见的,创建时打标无需多言,更新时的基本的判断原则应当是将已有的灰度数据作为判断标准,而不是以灰度key是否命中为判断标准。即后一步更新操作时总是以查DB的结果为准:DB中记录为灰度命中,那么就要执行新逻辑,否则按照灰度未命中的旧逻辑执行。

原则2:优先考虑灰度推进过程中数据的一致性

当灰度推进时,更多的用户或单据会被纳入到灰度命中的范围内,因此要考虑此部分数据能否进入新逻辑。

举例来说,以用户当月账单id尾号为灰度规则,那么用户的当月账单一旦被打标为灰度命中,后续账单再次更新时,也一定要遵循新逻辑;而在账单创建时如果为灰度未命中,那么这笔账单将会一直保持旧逻辑直到结清。

这个原则与前一条有一定的相似性,但核心关注的是灰度进展导致的灰度key命中情况在创建和更新两个阶段发生了变化,这时一般仍要遵循以DB记录打标为准的原则。

另一方面,灰度推进前已命中灰度的数据,要确保在灰度推进后仍能命中灰度。这是一条不言自明的规则,在确保数据一致性的基础上,只有这样才能被称为灰度推进。但在实际操作推进的过程中,有时会因灰度开关配置错误等原因违背了这一规则,因此可以考虑对配置项进行一定的防错设计。

此外,灰度推进过程中,还需要关注集群内各机器开关数据数据的一致性。首先要确保变更后的灰度开关值被推送到集群内的全部机器中,其次为了灰度推进时间的一致性,一般会在灰度开关内加入一个生效时间戳,避免开关推送延迟可能带来的问题。

原则3:如果需要快速推进灰度,可以尝试在第一个灰度维度全量后,再开始另一个灰度维度

上述原则中提到,更新数据时发现记录创建未打标的,即使灰度key已被命中,仍应使用旧的业务逻辑。但是这样做,整体的灰度进展将会被拉到非常长,比如确认收货后90天内都可以发起退款,那么是否要等到4个月之后才能全量切到新逻辑?业务上允许这样做吗?

对上述这个例子可以这样做,就是当订单创建已经全量灰度后,那么就可以理解为创建已经全部切入新逻辑,此时继续在付款或确认收货操作时进行灰度打标,这样仍然可以保持一次仅对一个变量进行灰度的原则。

这里给出几个不是很理想的快速推进灰度的做法。

1、同时对多个灰度维度做推进

这是上文在讨论简化灰度逻辑时,就力图避免的一种设计。与其这样做,还不如在验证充分之后,对第一个灰度维度直接全量,之后再推进第二个灰度维度。

2、在多个入口同时进行灰度打标

这个方式看上去可以加速消除数据创建时未打标的记录,但多个入口打同一个标,出现问题的时候怎么排查原因?更新时要不要覆盖创建时的标记?灰度暂停时如何同步停止打标?总之这是个复杂度高且验证工作量大的方案。

3、手工数据订正

既然要做数据订正,还不如在灰度启动前就做一轮,这样在整个灰度方案开始时就可以获得收益。灰度进展中做订正,成本更高,收益却更低,从ROI角度看很不划算。

灰度设计的过程中,不要轻易尝试去推翻上述这些简单的原则,因为越是简单基础的原则,其影响就越大,对这些原则的改动,往往会造成前述的设计被全盘推翻。

当然,也存在一些只创建记录,而不再更新的业务,这种业务考虑的重点往往不是灰度推进,而是下面的灰度暂停&回滚策略。

5 灰度暂停与灰度回滚

灰度是服务于安全生产的,那么相应的,一定要建立适当的熔断与回滚机制。

原则4:灰度过程务必具备整体暂停能力,也即灰度熔断。

灰度熔断不要求对已经进入灰度的数据进行纠正,而是只需要不继续产生更多的灰度数据即可。

为什么灰度不继续推进了还不行,还需要加入一个这样的开关?下面举个例子。

使用用户id尾号作为灰度key,已有n个用户进入新逻辑时,我们发现DB侧出现了瓶颈需要修复。这时业务层的应用有三种应对方式可选。

第一,立即缩小灰度范围或对代码进行回滚。这是不可取的,已经命中灰度进入新逻辑的用户,往往不能再轻易的回退到旧逻辑中。

第二,不继续推进灰度,也不操作灰度开关,放任系统继续运行。这也是有风险的,因为现阶段只有n个用户进入新逻辑,但按照用户群体总数*灰度比例测算,可能还有m个用户即将命中灰度进入新逻辑,甚至m>>n,如果DB问题不能在用户大量进入之前修复,整个系统将面临灾难性的后果。

第三,灰度熔断。不操作灰度开关,但停止新用户命中灰度规则,即目前有n个用户进入新逻辑,那么稍后即使有m个用户命中灰度规则,也仍然不能进入新逻辑,这样就可以确保在DB问题得到修复之前,系统保持现状继续运行。

通过这个例子可以充分说明,建设灰度暂停能力的必要性。

原则5:可操作的灰度回滚方案,才是有意义的灰度回滚方案。

一般来讲,我们都希望可以做到灰度的可观测、可回滚。但前文反复讲述的这类灰度方案中,避免出现数据一致性问题,对业务来讲才是更重要和更安全的。出现问题时机械的执行回滚,反而会造成更大的影响,而使用灰度暂停能力进行快速止血,并积极修复问题,反而是更合适的。

那么回到灰度回滚方案中来,在保证数据一致性等原则的前提下,可以设计一个合理的回滚方案吗?

我认为应该是可以的,但遗憾的是,我们在项目实践中没能成功的做到这一点。因为工程中的资源往往都是有限的,我们不可能把大量的时间和精力,投入到高度复杂的回滚方案中去。

因此对于灰度回滚方案,我有一些比较负面的结论: 灰度回滚方案的复杂性如果难以控制,那么正确性也将难以验证;

复杂的设计将带来开发周期和测试周期的延长,对业务的伤害可能更大;

就像应急预案需要提前演练一样,没有人敢在线上直接使用未经验证的回滚方案,最终做了也是白做。

所以,我建议仅在模型上更为简化的业务中,才去考虑设计完整的灰度回滚策略。

本章结语:

本章讨论的范围主要是从技术视角出发的,已经基本可以满足一个常规的灰度方案的设计要求。但可达成不代表做得好,除了技术手段外,还有更多的其他类型的手段可以应用到灰度方案中,帮助我们将方案变得更加完善、健壮,实现可观测、可度量等工程目标,构建起高质量的灰度设计方案。

三 更完善的灰度方案

1 具备良好的可测性

我们一般在复杂的项目下才会考虑使用较为细致或复杂的灰度方案,在项目本身的业务复杂度之上,再叠加灰度引入的技术复杂度,此时如何进行完备的测试就成了一个不小的挑战。我们需要明确,可测性问题是需要在设计中认真考虑的问题。让系统内的数据流动&状态迁移都是可观测的,把请求、处理数据的过程值、开关值、分支判断结果等信息明确的、无遗漏的持久化到日志或DB中,尤其是灰度是否命中、灰度判定规则等关键信息;而不要让复杂的系统变成一个黑盒,只有起始的输入和最终的输出。否则的话,在调试和测试的阶段,都要花费大量的沟通成本,甚至可能埋下无法被发现的缺陷。

对于日志的处理,应当尽量保持上下游的一致性。最好的代码是自解释的,最好的日志也应当是自解释的。上游的系统如果使用了一个灰度标记,则下游的系统应当使用相同的标记;如果有下游有业务语义的变化,可以新增一个字段,而不是将上游的同名字段覆盖或清除。这样在跨多个系统或团队进行联调或处理问题时,大家对同一个标记或概念都持有同一个理解,这是对提效非常有帮助的。举个具体的例子,比如上游命中AA规则后记录了AA=true的日志,下游根据AA规则衍生了BB规则,那么记录日志时可以保留AA字段的信息,再额外记录BB=true。

对于落库的数据的处理,则要考虑可核对性方面的问题。数据在跨系统传递时,应明确各个系统中的业务主键是什么,下游系统要将上游系统的主键或唯一键落库,如果条件允许,还要将上游传入的键值作为平铺字段,甚至为其在DB中建立索引。这样做的好处首先是为了后续建设核对方便,方便使用相同的唯一键查找上下游系统的关联记录。这也是为将来的系统扩展性做考虑,如果将来下游系统的下游还需要再接其他系统,此时通过上游的这个统一键值即可有效串联多个系统。典型的例子是将交易系统唯一id透传到下游的所有额度明细、账单明细、退款等系统中。

以上这些提升可测性的设计思路,不仅针对灰度方案,也针对不涉及灰度的方案;不仅需要测试同学在设计阶段识别和发现方案的可测性短板,也需要开发同学有意识的去面向可测性进行设计。

2 关注全链路的压力

系统改造中需要关注对下游依赖的压力变化,灰度设计中也需要考虑这一点,尤其是系统压力随着灰度推进而改变的情况。

一种典型的场景是随着灰度推进,传递到下游的请求越来越多,这是一个比较好理解的例子,这里不做过多展开。这种情况下,主要需要梳理下游请求的增长率,是随着灰度推进线性增长、对数级增长、还是指数级增长(指数的情况就很可怕了,极易引发故障)?此外,灰度推进结束之后,流量模型是相对稳定的、还是继续变化的?

实际业务中的情况并不都是这么简单,有的场景中,在灰度开始启动的时,才是下游流量最大的时候。随着灰度的推进,下游的流量反而会越来越小。举例来说,开始灰度后,全量用户都需要查询某服务,而命中灰度的用户可以通过其他短路方式规避这个查询。如果评估发现这种特殊情况,除了按照常规做出压力评估,也可以考虑对依赖方案做调整以规避这种反直觉的情况。

其他的可能性还会有很多,再举一个极端的例子:本月的流量是随着灰度推进逐渐上升的,经过N天后灰度推进至全量,流量保持稳定;但到了下月1日,业务数据需要重新生成,由于已经灰度全量,导致突然爆发出了非常大的流量,给下游系统带来很大的影响。这种极端的场景如果不能提前发现识别,并做出合理的应对,则可能引起意想不到的严重故障。

除了下游的业务系统,我们还要关注DB侧可能存在的瓶颈,我们的业务系统一般可以快速地进行集群平行扩容以应对大流量,但DB的扩容就比较复杂了,可能会涉及到数据迁移、锁库、索引重建等操作,有些操作属于高危操作,如有不当,甚至会影响使用同库或同表的其他业务。当识别到这类问题时,需要提前与DBA联系,讨论合理的扩容方案,在灰度启动之前,预留充足的时间完成扩容。

当然,所有的压力评估,都可以用压测来进行检验。但是要把压测当做对设计成果的验收,而不是作为发现问题的兜底手段。即将上线之前才发现系统性的问题,可能为时已晚,强行上线或延期,成本都将是巨大的。

3 灰度的进展与监控

首先,监控是灰度前期最重要的观察手段,建立完整全面的监控,对于上线初期、灰度开放初期、灰度放量初期的数据观察,都至关重要:上线初期我们重点要关注新代码下的旧业务逻辑是否能正常运行;灰度开放初期则要观察何时出现命中新逻辑的数据,以及进入了哪些业务分支;灰度放量期间则要观察流量的变化是否能与开关调整相匹配,报错量是继续处于低位、还是随之线性甚至更快速的增加。此外,灰度放量过程中所关注的监控,在后续灰度全量后也仍然需要持续观察,有些还要建立相应的报警规则。

其次,针对灰度方案的核对也有一些不同。常见的核对一般都是以上游系统对应的A表作为左表(即核对数据源),下游系统的B表作为右表。但是下游系统在灰度阶段会将上游数据做二分类,命中灰度的写B表,未命中的不写,此时建立核对就要反转过来,将下游灰度命中后写入的B表作为左表,反过来与上游A表建立核对,确保所有命中灰度的数据仍与上游保持正确的关联关系。

但这里又有一个问题,一个用户本应命中灰度,但却没有写入B表,我们如何发现这类问题?这里我提出一个解法,即仍然建立从A到B的核对,但在核对规则中加入灰度规则的等效条件语句,并随着灰度推进修改核对规则。但这样做,核对规则将会非常复杂,而且也对如何设计落库字段提出了更高的要求。

最终,还可以为整个灰度方案建立一个小型的报表用于速查,从落库结果的角度判断某个特定的用户或数据是否已经命中灰度。更进一步的,还可以在报表中展示灰度相关的聚合与统计数据,判断数据分布是否符合灰度推进节奏,下一步需要加快推进还是暂缓。借助这些数据,一来为技术侧同学在做答疑或问题排查时提效,二来可以向业务侧的同学提供灰度的整体数据或局部情况,以便做出更多业务决策。

4 应急策略与修复手段

灰度推进过程中,我们可以通过各种方式和渠道获取来自系统和用户的反馈,包括但不限于监控、核对、用户咨询等。当发现不符合预期的、甚至存在严重问题的数据与场景时,标准的操作是先止血再修复:通用的止血方案可以是先将业务逻辑开关关闭、下线或回滚掉新逻辑的代码;随后在修复阶段对错误的数据进行订正。

但是回到最初讨论的问题,我们为什么要做灰度?灰度本身存在的价值就是在问题初期可以控制影响面,如果只是机械的执行上述的通用方案,为何还要设计复杂的灰度方案?

比如灰度方案中的止血开关,可以设计成全量下线新逻辑,也可以设计成不再产生新的灰度用户,这个已经在上一章中举过例子,这里不再赘述;在有多个灰度维度时,还可以设计成调整A维度与调整B维度可分别实现前述的目的。但需要明确的一点是,止血开关的语义应当设计的尽量简洁、无歧义,因为止血的意义就在于短时间内无需复杂判断即可立即执行。

详细判断与分析的工作,是在下一步的修复阶段完成的。上一章也提到,一般意义上的回滚可能引发更大的问题,所以可以在修复代码逻辑或修复数据后继续推进灰度;当然,进行灰度范围的回滚也是可选项,比如撤销之前已经命中灰度的用户或单据,将其修改为未命中。但这类回滚除了要考虑前述的数据一致性、系统复杂度等问题,还要从业务逻辑上看能否做到前向兼容,从产品视角去考量用户体验能否在回滚后得到保障,这也是一个要不要去做这类复杂灰度回滚方案的重要判断依据。举例来说,前一天用户命中了灰度,可以使用某个新功能;但第二天灰度回滚后反而不能用了,这大概率会引发用户咨询甚至投诉。

安全生产应当摆在重要的位置,但工程的目标从来不是单一的。灰度系统的设计在这个部分上一定要有所取舍,并不能一味地从系统稳定性的角度出发贪大求全,而应当结合实际业务情况,平衡由于复杂度提升而引入的设计、开发、测试、运维成本,和对产品、用户体验的影响。

5 灰度方案的终点

讨论到这里,我们基本把最复杂的部分与可能面临的失败情形都讲完了。下面谈谈在灰度进展一切顺利的情况下,还有哪些事项需要我们关注。

首先是灰度完成后,对灰度开关的下线。最明显的好处是简化代码复杂度,已经完成的灰度,基本等同于无用的业务代码。此时还可以把旧逻辑的代码也一同下线,全部直接执行新逻辑,也方便后续其他同学阅读与维护。不过这一步并不是非做不可,而且可能还会遇到一些限制。

灰度的终极目标当然是全量切换到新逻辑中,但实现这个目标有时候需要花费很长的时间。举个业务上的例子,比如从5月的某一天起开始灰度一个远月账单相关的功能,这时已经有部分用户产生了8月份的非灰度账单,那么按照预期,就要等到9月之后才能在理论上实现全量账单命中灰度。出现这种情况时,一般要和业务方充分沟通,因为业务上可能无法容忍漫长的灰度周期。压缩整体周期的手段,除了将灰度开关推到全量,还需要通过数据订正等方式让加速数据层面的灰度推进。

不过真实的情况可能更复杂,继续拿上面的例子来说,这笔8月账单如果出现用户逾期,那到9月时也仍然没有实现全量。在交易相关的系统中也有类似的例子,付款、确认收货、退款,每个周期都可以很长,如果再遇到纠纷等场景叠加在一起的情况,周期更是不可控,所以基本不可能在设计中对这类长尾值进行良好的处理。一般来说,在整体趋近于全量后,总会有个别的异常数据、离群数据的出现。所以从工程的角度来看,只要非灰度的数据趋于收敛,就是符合预期、可以接受的情况。

6 灰度的代价

前边我们反复提到过,在设计灰度方案中要有所取舍,尤其是要对最复杂的部分做取舍。不过简单的部分就没有代价了吗?不是的。项目中实现了一套灰度方案,就一定会付出相应的成本。应当总是从成本收益比的角度出发,来评价一个设计方案的价值,进而决定其最终应被保留还是被舍弃。

首当其冲的代价,就是复杂度的提升,这一点已经在上文多次提到。一般来讲,我们是能够在复杂项目中承受这一点的,因为项目本身的复杂度已经不低了,从边际效应的角度来理解,再加一点复杂度也不会造成太多额外的开销。但是对简单的项目,我们就要思考是否需要采用灰度了;或者出于安全生产的需要,我们一定要进行灰度,那简单的项目也一定要匹配最简化的灰度方案,避免造成大炮打蚊子式的浪费。

其次要面临的问题是发布时间延迟。设计、开发、验证环节都要花费额外的工作量和研发周期,来保障灰度逻辑的正确性与有效性,而且大概率还会出现修复灰度问题的时间。如何在项目开始时合理评估灰度引入的工作量,也不是容易的事情,因为灰度逻辑往往与业务逻辑正交。单从用例数量的视角看,理论上每增加一个灰度开关,相关的功能用例数量就要翻一倍。当然我们可以结合实际业务排除一些用例,但这样的用例数量增长趋势对于项目整体而言,并不是一个好信号。

接下来还有一个问题,就是灰度会使项目周期拉长。这里的周期指的是从发布后到灰度全量的周期。上面已经有案例说明,5月开始灰度的项目,到9月甚至更晚才能真正完成全量,这听起来就让人难以接受。极端地,这种漫长的灰度过程还可能会影响下一个项目的设计与上线,甚至会将影响扩散到下游系统中。如果出现这种情况,已经可以算作是设计失败了。

最后就是要慎重考虑不做灰度。不做灰度其实是反规则的,一般我们也不建议这样做。但工程上的事情总会有例外,有时也会遇到一些业务场景无法灰度,或者灰度还不如不灰度。如果决定不做灰度方案,那最好把灰度带来的问题和不做灰度的收益都提前整理好,同时也要充分评估放弃灰度的风险,让项目组的其他同学都能理解认同这个决策。

本章结语:

一个项目或产品的质量从来不是测试测出来的,而是在设计阶段就构建起来的。希望通过上述两章提及的各类设计手段与思路,给大家更多的输入与启发,在将来设计构建出更稳定健壮的工程。也欢迎大家对文章中的各项内容给予补充、指正。

下一章将从测试的角度讨论如何保障复杂灰度方案的正确性。

四 灰度方案的质量保障

之前的章节主要针对灰度方案的设计展开,但一个系统的正确性、稳定性,除了要依赖有效的设计,还需要全面合理的测试来保障。这一章就对灰度方案的质量保障体系进行详细的讨论,列举灰度系统中的各个测试覆盖要点。

1 灰度基本逻辑

这是最基础的测试点,即如何将数据非此即彼的区分开:满足预设的条件即为命中灰度,否则不命中灰度。

灰度命中的结果,不仅要是可预期的,还应当是稳定的。使用同样的数据与配置,不能在某次请求时命中灰度,另一次请求时却未命中灰度,否则将产生严重的问题。

举例来说,如果用户A在第一次命中灰度后,在将命中结果落库时,但意外的影响了灰度判断条件,那么稍后用户A再来请求时,就可能出现无法再次命中灰度的问题。这类型的低级缺陷要尽早发现,否则会阻塞后续的其他测试。

2 灰度命中后的持久化

命中灰度的数据有时还需要持久化到数据库中,在测试中除了检查灰度标记,还要检查新增的字段。如果灰度命中后写入全新的表,也要对全部字段进行完整的校验。

落库的数据与上游有关联关系的,要检查记录是否一致,如果可行,最好推动开发将其设为平铺字段,方便在上下游间建立核对。单据号等字段具备唯一性的,要额外做幂等性测试,防止同一条灰度命中数据多次写入。

除了结果数据,过程数据也至关重要。判断灰度过程中可能经历了多个条件,那么需要将每个条件的输入值、判断结果值都打印在日志中,方便联调与后续问题排查。此外还要检查日志中变量名的唯一性与变量值的正确性,防止打印语义混淆的废日志。

3 灰度兼容性

由于灰度过程中的请求会分为两部分,因此系统内应当对两类请求都有相应的处理能力,即在灰度全量之前,旧逻辑仍要保持可用。

新版本代码中的旧逻辑,在本质上已经和上一个版本的逻辑有所差异,因为上一个版本中是未经灰度判断,直接执行旧逻辑;而新版本代码中是多一层判断逻辑的。这层判断逻辑有时可能还会在入参上添加各类标记后,再进入系统的下游模块流转,并会引发更多复杂的情况。比如系统对未命中灰度的数据加入了一个属性,但下游流程判断有任意标记的流量都不再处理,那么这种情况下的旧逻辑就会受到影响。

如果灰度系统涉及多个应用,还要考虑应用间的兼容性。常见的测试要点包括:

灰度系统是否影响了上下游的流程交互,如命中未灰度走A应用,灰度命中则不走A应用,这样对A应用的监控和核对是否会造成影响;

灰度是否新引入了下游依赖,原有的依赖关系是否被解除或需要削弱,强弱依赖的设计是否合理,具体的依赖关系如何,是否引入了循环依赖,或者数据流是否构成回环;

上下游应用均有改动时,在下游应用先行发布后,是否会影响尚未发布的上游应用。

4 灰度推进

灰度从0开始,到部分覆盖,再到全量覆盖,这个灰度推进的过程也需要测试重点关注。

首先是一头一尾的情况,灰度开关配置为全量老逻辑和全量新逻辑的情况下,请求的结果是否符合预期;

其次是灰度推进的过程中,如果用户A在上一次请求时未命中灰度,但下次请求时由于灰度范围扩大而命中了灰度,那么用户A的请求能否正常处理?用户A能否按照预期被纳入或排除出灰度新逻辑的范围内?

最后还要评估灰度推进可能引起的兼容性问题,这里要关注的点是在灰度开关变化的情况下,动态的评估内部逻辑的兼容性,而这可能是上述静态的兼容性测试不能覆盖的点。这里需要结合实际业务与设计方案仔细分析,排除可能的、隐藏较深的、重现条件较为复杂的缺陷。举例来说,当月A用户第一次请求时未命中灰度,故写入一条不带灰度标记的记录,意味着本月A用户将不再命中灰度;当A用户第二次请求时,查询是否存在灰度不命中的记录时服务超时,且由于灰度推进导致A用户变为灰度命中,故又写入了一条带灰度标记的记录,导致库中同时存在两条业务语义存在相矛盾的记录。

5 灰度暂停或灰度熔断

上文已经反复讲过,灰度熔断的功能对灰度方案至关重要,在某些关键时刻甚至是系统唯一的逃生路径,因此对这里需要格外重视。

第一,熔断开关关闭时,要确保没有新增的灰度流量进入。这里有两层含义,一方面是未命中灰度的数据不能再命中灰度,另一方面是已经命中灰度的数据,要视灰度系统是否可回滚、是否前向兼容,决定是否可以继续命中灰度。

第二,熔断开关关闭时,要保证其他部分的灰度逻辑不受影响,这也是基本逻辑测试的一部分。

对此类应急方案的测试,还需要结合实际业务场景进行设计考虑,比如存在多个其他业务逻辑的开关时,是否要对所有开关组合进行测试,还是优先测试业务实际使用的组合,或者仅测试应急场景下必定出现的且数量有限的几个组合即可。

6 灰度回退

上文提到,在可能涉及灰度数据一致性问题的灰度方案中,我们一般不推荐引入复杂的灰度回退逻辑。但不可否认的是,灰度回退在部分场景下仍然是有价值的,此时也需要通过测试手段保障回退能力的质量。

首先是回退过程不再新增灰度命中数据。这里的保障要点,与熔断开关打开后是一致的。

第二是回退过程中,已命中灰度数据的一致性保障,这里最需要关注的场景是,在前一个业务流程中已经命中灰度的数据,在下一个业务流程中没有命中灰度时,系统将会如何处理。如,订单创建时命中灰度并打标,付款阶段反而不命中灰度,则此时需要将灰度标记移除。

此外,还要对灰度开关的回退能力进行测试,如果灰度开关存在多个维度或限制条件,这里的测试用例组合也会非常复杂,但与灰度推进逻辑的测试方案有一定的相似性,可以作为参考。

最后,灰度回退的过程一般还需要借助数据订正的手段对已经落库的灰度数据做变更,这里不涉及代码流程的测试,可以考虑建立核对规则进行保障。

7 对异常配置的容错

灰度逻辑底层常会依赖一个switch开关或diamond配置项,但进行配置时也有可能引入错误。把整个系统看成一个木桶,那么配置项常常是最短的那块木板。我们应当通过优化设计,规避由配置类问题导致的更严重问题。

首先,对灰度开关错配时,应用不能接收,仍应使用上一次的正确配置。虽然在diamond配置项中输入错误的配置值后,中间件层总会将这个错误值持久化,但应用可以在此时报错,并弃用中间件下发的错误值。

此外,如果在业务上有可行性的话,还可以在每次接收到错误值时,采用默认值来做兜底处理。

典型的例子是,前一版本的灰度配置包含尾号为00、01的用户,而后一版本的灰度配置中只包含尾号为0的用户,不包含尾号为1的。如果这个配置生效,那么尾号为01的用户的数据一致性将被破坏;此时若对后一版本的配置做校验,识别发现尾号为01的用户原本可命中灰度,但在推进后反而不命中,则可以避免这个问题。

这一点既是测试设计要考虑的异常逻辑,也是方案设计阶段需要考虑的防错机制(Poka-yoke)。

8 对异常数据的容错或报警

如果灰度过程中发现缺失了某个新字段,但可以通过一定的回补机制写入的,那么最好可以进行静默处理,容忍这样的错误数据。比如本应在用户浏览商品时对用户打上灰度标记,但后续加购时发现灰度范围内的用户仍然不带灰度标记,则此时可以再次对用户进行灰度打标。

但如果系统中核心依赖的字段遇到数据一致性错误时,就应当立即停止继续处理。如一个已经带灰度命中标记的订单,在确认收货时,缺少了一个应当在付款阶段写入的关键的新字段。那么此时应当不作处理,通过记录错误日志、抛出异常等手段,触发外部的监控报警,等待人工介入。

这里可以借助异常注入类的工具来简化测试方案。通过破坏灰度数据的一致性,检验系统对异常数据的处理是否符合预期。这部分功能的正确性,在遇到灰度回退等复杂情况时,将会起到很大的作用,如首次请求灰度未命中、二次请求时进行灰度命中补偿;或回退时数据订正不完全,系统处理此数据时触发报警,提醒再次订正等。

9 对外部系统的影响

除了要关注业务系统内部的数据流转情况,有时还要考虑对外部系统影响,比如在执行到某个节点时对外发送消息,而下游有若干外部业务方的监听者需要在收到消息后执行对应的系统逻辑;或者最常见的,落库的数据会定时的写入离线数据表中。

对外部系统的影响,应该在变更前期、设计方案确认后等关键节点,及时向下游业务方同步,评估下游需要的改动,并在预发环境进行有效的串测、验收,如有必要还要为新逻辑产生的数据单独建立监控或核对;此外,在灰度推进阶段需要向下游同步灰度变化节奏,观察监控变化情况是否符合预期。

对离线表的影响有两方面,首先要为变更的部分建立新的核对规则,其次也要评估对原先建立在这些离线表上的核对规则是否有影响,是否会导致核对误报或漏报。

10 灰度流量模型分析

灰度过程的流量模型是动态变化的:首先,在灰度未开始推进的初始状态下,就已经与上一版本的流量模型存在一定差异;随后随着灰度的推进,流量模型又会逐渐发生变化;最终在灰度全量后达到稳定。

在变更上线后、灰度启动前的阶段,一般不会与上一个版本的服务或DB依赖存在太大的出入,否则这些变化也应当被纳入灰度流程。这阶段主要需要对服务调用和DB新增字段进行评估,判断是否存在复杂的计算逻辑,或对DB读写存在影响。

相比之下,灰度推进阶段需要分析的点会比较多。灰度推进过程中,灰度判断逻辑的查询接口,按灰度命中结果分流后两套业务逻辑接口,落库时的DB,其他依赖或下游方的流量,都在同步的变化着。这里需要对这些变化点做逐个梳理,再分析流量变化可能引起的后果。

下面列举几个常见的随着压力逐渐变大,性能出现较大问题的场景:

  • 触发下游服务限流,导致本系统的业务失败率升高;
  • 下游服务rt变长,本系统业务随之超时,失败率升高;
  • 灰度推进时,命中灰度的key值选取不当,经过分库分表规则后,导致单库热点;
  • 灰度推进时,推进范围过大,导致短时间写库请求过大,引起整库流量或性能抖动;
  • 为灰度字段添加的DB索引,不适用于灰度推进过程中的流量模型,导致DB性能不及预期。

灰度全量之后,流量模型将会达到或逐步达到一个新的稳定态,除了继续观察上述灰度推进过程中的各个要点,还要考虑在全量之后做切换的动作,比如对灰度判断逻辑做短路,以减少一次查询;或者将灰度条件的查询操作,从一个接口迁移到另一个性能更好的接口上。总之这个阶段可能只有性能优化,不太会有让整体性能变差的情况,此类优化除了确保基本功能的正确性外,无需过多关注。

11 对灰度系统进行压测

从上一节的列举的情况看,压力瓶颈常会出现在新增的服务接口与DB这两处,需要结合业务具体分析。但分析并不是万能的,新的接口或新的库表在上线前一般要按照规划的流量要求进行一轮压测,确保没有因为分析遗漏导致的隐藏缺陷。

压测流量的设定,需要结合当前线上业务的接口调用量进行评估,可按灰度全量后的流量值再放大1.2~2倍计算。放大的目的一方面是为了应对峰值流量,另一方面是为了快速暴露问题。常见的问题是流量在下游被成倍放大,比如一次请求,调用了两次某接口,当流量较小时,二者间的倍数关系体现的不明显,可能还会被误认为同时间段线上真实流量增大引起的扰动,导致无法发现问题;但流量较大时,倍数关系将会立即显现。

如果压测流量较大,需要在发布上线后使用线上集群做压测,那么还要考虑影子数据与真实数据隔离的问题。使用影子请求压测时需要按照全量灰度命中的新逻辑来执行,而对线上真实请求还不能开放灰度。这种情况需要在代码中额外添加一个供压测使用的开关,通过在入口处判断请求的压测流量标记字段,判断是否执行灰度逻辑。

12 为灰度建立核对规则

为了保证新项目上线后及时发现线上数据可能存在的问题,最晚在灰度启动之前就要将相关的核对规则全部上线。

在灰度项目中,常会出现灰度命中与未命中时落表不一致的情况。此时建立核对就要考虑如何选取左表。我们把系统的全量请求作为全集,把命中灰度的部分作为子集,那么灰度命中子集中的数据,必然要与全集的数据保持一定的关系。反之则不然,因为全集中还有部分灰度未命中的数据,无法与灰度命中子集中的数据保持一致。

举例来说,灰度系统位于下游,需要与上游系统进行核对,确保上游发来的请求全部被正确的处理了。这时就要用命中灰度之后的表作为左表,上游请求的表作为右表来建立核对。

此外,对于灰度未命中的部分也需要建立核对来保障一致性。这里的处理方式有两类:如果灰度命中只是新增落表,而不影响原有落表逻辑,那么可以先为旧逻辑做全量核对,即在灰度启动后,无论是否命中,都仍然应当遵循旧逻辑下的一致性约束;如果灰度命中后数据将会从旧表中迁走,只写入新表,就需要对灰度未命中的部分也进行上下游关系、子集全集关系的分析,然后选取子集作为左表建立核对,这与灰度命中的处理方式是相似的。

上述的原则可以帮助我们检查在灰度命中与未命中两种情况下,数据总是一致的,但是无法确保灰度命中与否这一结果的正确性。要想确保这一点,主要还是要依赖基本的功能测试,其次可以考虑在核对规则中引入与灰度规则等价的条件语句,并在每次灰度推进之后,同步修改这个条件语句。不过这种解法一般只能用在实时或准实时核对中,对离线数据的核对可能并不适用,因为离线表中历史数据所遵循的灰度规则,与当下的灰度规则可能是不一致的。如有需要,可以通过手工单次查询离线表,并结合灰度开关操作记录对结果进行判断。

最后,对于灰度系统的核对规则,我们还要适当提升时效性,因为从发现问题效率的角度讲,实时类型的核对是远优于离线与隔日核对的。灰度初期发现问题的概率更大,修复的成本也更小,但前提是能够及时的发现。

本章结语:

灰度方案的质量保障策略与设计策略是相匹配的,复杂的灰度系统设计一定会对应复杂的灰度测试方案。回到灰度本身的意义来看,它本就是服务于安全生产的,因此对灰度系统进行良好的全面的测试覆盖更是底线中的底线,务必要作为测试工作的重点。本文在此抛砖引玉,希望大家能在灰度质量保障这个话题上,分享更多的经验与心得。

原文链接

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

本文转载自: 掘金

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

vivo商城计价中心 - 从容应对复杂场景价格计算 一、背景

发表于 2021-08-23

一、背景

随着vivo商城的业务架构不断升级,整个商城较为复杂多变的营销玩法被拆分到独立的促销系统中。

拆分后的促销系统初期只是负责了营销活动玩法的维护,促销中最为重要的计价业务仍然遗留在商城主站业务中,且由于历史建设问题,商城核心交易链路中商详页、购物车、下单这三块关于计价逻辑是分开独立维护的,没有统一,显然随着促销优惠的增加或者玩法的变动,商城侧业务重复开发量会显著加大。

促销系统的独立,计价相关业务能力从业务边界上也应由促销系统提供,因此促销侧需要从头开始设计促销计价相关能力。

二、原有计价业务

2.1 计价业务场景

商城原有涉及到计价业务的主要是商详页、购物车、确认下单、提交订单这几个业务场景。

如果将每一个影响最终售卖价的优惠叫做计价因子的话,那前述几种场景下对于售卖价有影响的计价因子归为三大类:

  • 优惠活动(单品优惠、订单优惠)
  • 优惠券(优惠券、代金券)
  • 虚拟抵扣(积分、换新鼓励金)

对于每种计价场景与计价因子有如下关系:

2.2 原有计价模型

对于具体执行的计价业务中各计价因子间是有一定的先后优先级关系的,综合如下图所示,也在一定程度说明了原有计价业务模型:

三、促销计价模型

3.1 分层模型

促销系统从零搭建基础计价能力,对于系统的稳定性及扩展性必须有一定的保障,而这也就对于促销系统的计价模型提出了一定的要求,通用的基础计价模型最好是能有过一定的实践经历验证过的,因此我们采用了传统电商久经考验的计价模型:分层计价。

所谓的分层计价即传统电商中优惠涉及的三个层面:商品级、店铺级、平台级,正常情况下不同级别的优惠默认是可以叠加的,同一级别的优惠默认情况下是互斥的。

这里需要说明的是,每一层级的优惠计算的时候,对于有些优惠的门槛条件是否满足需要依赖原价,默认情况下依赖于上一个层级的优惠计算后的价格,即商品级优惠计算依赖商品原价,店铺级优惠依赖于商品级优惠计算后的价格,平台级优惠依赖于店铺级优惠计算后的价格。

叠加规则特别说明:

正常优惠叠加是指两个优惠可以同时享受,对于不同层级的优惠默认就是叠加的,对于同一层级的优惠默认是不叠加的,比如正常情况下,优惠券下的各种类型券是只能用一张的。

但某些场景下,业务上会指定同一层级的优惠可以叠加使用的,同时指定叠加使用的场景下还会分为普通叠加和并行叠加,举个例子:订单优惠和优惠券这两个类型的叠加就属于普通叠加(优惠券门槛是否满足的判断取决于订单优惠后的价格),优惠券和代金券的叠加属于并行叠加(优惠券和代金券的门槛是否满足的判断都取决于这两者的前序优惠后的价格)。

对于同一层级的优惠按不同维度分为:必选/勾选、可叠加(并行叠加/普通叠加)/不可叠加 。

3.2 新的计价模型

3.3 核心计价流程

3.3.1 主流程

通过前述计价模型可以得知,在计算优惠价时的先后顺序是:商品级(CalcItem)、店铺级(CalcShop)、平台级(CalcGroup),另外根据一些特殊业务场景,增加了可能的中断业务逻辑(CalcInterrupt),因此可得到下图所示的最粗粒度的计价流程;

那这三个级别的计算优惠价内部又是如何实现的呢?经过业务抽象,这三个级别的计算可以变成一个通用的计算优惠逻辑,仅有优惠级别的区分。

3.3.2 通用流程

经过业务抽象发现三个级别的优惠计算的通用逻辑:

  • 获取当前层级的优惠查询器(Get Current Level PromotionGetter)
  • 过滤优惠查询器(Filter PromotionGetter)
  • 查询优惠(Get Promotion)
  • 过滤优惠(Filter Promotion)
  • 通过计价引擎计算优惠(Calc Engine)
  • 过滤计价结果(Filter CalcResult)

因此我们得出如下的通用的计价流程:

通用计价流程中的又有几个相对灵活的与业务相关过滤逻辑,从后面的细节流程中可以了解更多的实现。

3.3.3 细节流程

之所以在通用计价流程中会有几个过滤节点,是因为在业务上会有一些特殊的过滤逻辑,比如商详页来源的时候,只能使用商品级优惠查询器,某个优惠只能特殊渠道去享受等等。

所以需要抽象出一个通用的可扩展的过滤机制来实现业务需求,因此会按照不同维度去定制一些链式过滤器,执行流程如下图所示:

当然图中所示的不同维度额过滤器只是目前业务中的一部分,比如还有按照终端、付款方式、外部业务方等等,这些在具体实现的时候可以非常灵活的支持。

那上述过滤器是如何制定?以及与业务如何关联的?

上图中列出部分业务定制过滤序器,自定义过滤器后会自动注册到统一的优惠业务过滤器工厂中,在前述的计价流程中,需要用到相关过滤器时,只需带上相关上下文参数可以自动从过滤器工厂中获取匹配的过滤器。

3.3.4 完整全流程

把前面这一系列流程中进行一个组合拼装,就可以得到计价的完整全流程图,如下:

从这个完整流程图中,可以看到一个通用稳定的核心计价流程以及一个支持业务多变的定制过滤器,既保证了核心的稳定,又保留灵活的扩展。

四、系统核心设计

在通用的计价执行流程中一个节点是「Calc Engine」,也就是计价引擎,这是整个计价逻辑中最核心底层的能力,由它来判定每个优惠是否能被用户享有。

4.1 统一优惠模型

由于计价中心在建设的时候,已经存在了促销系统中的各个优惠活动、独立的优惠券及代金券、遗留在商城主站的未迁移的优惠,因此想用兼容这么多的优惠类型,必然需要建立一个统一的优惠模型,而在建设过程中需将现有的优惠模型进行适配转换至统一模型。

统一优惠模型中的一些关键信息有:优惠标识、优惠类型、优惠模板id、开始结束时间、优惠参数及一些扩展参数等。

4.2 优惠模板

1)在进行促销计价时,每个具体的优惠都会对应一个唯一的优惠模板,每个优惠模板本质上是一个JSON字符串,只是这些JSON字符串是由遵循了一定特殊逻辑规则的元信息数据转化而成,而这些元信息在被计价引擎解释执行时,都是返回布尔类型标识是否通过。

2)基本的元信息数据有这几种:

**AndMeta(与)**对应逻辑关系中的“与”关系,表示该类型的元信息所包含的子元信息解释执行都返回真才为真;

**OrMeta(或)**对应逻辑关系中的“或“关系,表示该类型的元信息所包含的子元信息任一解释执行返回真就为真;

**NotMeta(非)**对应逻辑关系中的“非”关系,表示该类型中元信息所包含的子元信息解释为假当前元信息为真;

**ConditionalMeta(条件)**如果条件参数不存在或者从上下文获取参数指定的布尔值不为true,则当前元信息返回真,否则根据元信息中包含的子元信息解释执行的结果作为当前元信息执行结果;

**ComplexMeta(组合元信息)**该元信息作为所有模板的通用载体,该元信息中包含两个重要信息conditon、action,两者的关系是只有condition条件都满足后后,才会去执行后续的action,而condition和action都可能为前述中的各种元信息的复杂组合。

3)模板元信息关系:

4)优惠模板示例:

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
java复制代码{
"type": "COMPLEX",
"condition": {
"type": "AND",
"metas": [
{
"type": "CONDITIONAL",
"metas": [
{
"type": "CONDITION",
"metaCode": "terminalCheckCondition"
}
],
"param": "needTerminalCheck"
},
{
"type": "CONDITION",
"metaCode": "amountOverCondition"
}
]
},
"action": {
"type": "AND",
"metas": [
{
"type": "ACTION",
"metaCode": "cutPriceAction"
},
{
"type": "ACTION",
"metaCode": "freezeCouponAction"
}
]
}
}

4.3 计价引擎

计价引擎本质上就是对应优惠模板的解释执行,并配合相关上下文,进行优惠计算,关键代码如下:

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
java复制代码private boolean executeMeta(Meta meta, EngineContext context) {
if (meta instanceof AndMeta) {
return executeAndMeta((AndMeta)meta, context);
} else if (meta instanceof OrMeta) {
return executeOrMeta((OrMeta) meta, context);
} else if (meta instanceof NotMeta) {
return executeNotMeta((NotMeta)meta, context);
} else if (meta instanceof ComplexMeta) {
return executeComplexMeta((ComplexMeta)meta, context);
} else if (meta instanceof ConditionalMeta) {
return executeConditionalMeta((ConditionalMeta)meta, context);
} else {
return executeIMeta(meta, context);
}
}

......

private boolean executeComplexMeta(ComplexMeta complexMeta, EngineContext context) {
Meta condition = complexMeta.getCondition();
Meta action = complexMeta.getAction();
return executeMeta(condition, context) && executeMeta(action, context);
}

private boolean executeConditionalMeta(ConditionalMeta conditionalMeta, EngineContext context) {
PromotionContext promotionContext = context.getPromotionContext();
if (promotionContext == null || promotionContext.getParameters() == null) {
return true;
}

String conditionParam = conditionalMeta.getParameter();
String sNeedProcess = promotionContext.getParameters().get(conditionParam);
if (sNeedProcess == null) {
return true;
}

boolean needProcess = Boolean.parseBoolean(sNeedProcess);
if (needProcess) {
return executeMeta(conditionalMeta.getMetas().get(0), context);
} else {
return true;
}
}

private boolean executeIMeta(Meta meta, EngineContext context) {
IMeta iMeta = MetaFactory.get(meta.getMetaDef().getMetaCode());
if (iMeta == null) {
throw new CalcException("meta not found, metaCode=" + meta.getMetaDef().getMetaCode());
}

return iMeta.execute(context);
}

五、小结

通过前面几章内容的描述,我们基本把vivo商城促销系统建设计价中心的关键思路阐述完了。建设完计价中心后,整个促销系统的核心基础才立住,但这也只是个开始,整个商城围绕着促销计价中心仍然还有其他待建设的内容,比如整个商城的营销价格能力矩阵,价格监控,商城时光机等等,而这些内容我们后续有机会也会陆续输出相关文章,与大家一起交流学习。

作者:vivo互联网服务器团队-Wei Fuping

本文转载自: 掘金

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

HTTP协议之 HTTP/11和HTTP/2 简介 HTT

发表于 2021-08-23

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

简介

HTTP的全称是Hypertext Transfer Protocol,是在1989年World Wide Web发展起来之后出现的标准协议,用来在WWW上传输数据。HTTP/1.1是1997年在原始的HTTP协议基础上进行的补充和优化。

到了2015年,为了适应快速发送的web应用和现代浏览器的需求,发展出了新的HTTP/2协议,主要在手机浏览器、延时处理、图像处理和视频处理方面进行了优化。

基本上所有的现代浏览器都支持HTTP/2协议了,但是还有很多应用程序使用的是老的HTTP/1.1协议,本文将会介绍HTTP/1.1和HTTP/2的不同之处。

HTTP/1.1

HTTP 1.0 是由Timothy Berners-Lee在1989年作为World Wide Web的标准协议发布的。通常使用HTTP方法如:GET或者POST,以TEXT文本的形式在客户端和服务器端进行消息传输。

我们可以使用post man很方便的进行HTTP请求,如下所示:

1
2
vbnet复制代码GET /index.html HTTP/1.1
Host: www.flydean.com

客户端通过GET请求,请求服务器端的/index.html页面,使用的协议是HTTP/1.1,服务器端收到该请求之后,会将相应以文本的形式返回给客户端。

HTTP协议是对底层的TCP/IP协议的封装,因为我们不需要交接具体的报文拆分和封装的底层细节,只需要关注于具体的业务逻辑即可,非常的方便。

HTTP/2

HTTP/2是从SPDY协议发展出来的,它的发起者是Google,最初是为了在web交互中引入压缩、多路复用等新的技术,最终在2015年被作为HTTP/2协议的一部分。

之前我们讲到HTTP/1.1是以文本的形式进行传输的,这样的缺点就是数据占用的空间比较大,相较于HTTP/1.1来说HTTP/2使用的是二进制进行传输的,使用二进制对消息进行封装,同时保留了HTTP的语义,比如方法,头等。

这种二进制的封装对应用层是无感知的,对于应用来说,还是按照常用的方法来创建HTTP请求,将其封装成二进制的工作是由HTTP/2来完成的。

传输模式对比

在HTTP1.0中,每当客户端向服务器端请求页面的时候,往往返回的不是一个完整的页面,而是这个页面还需要的额外的资源链接信息,因为完整的页面需要所有的资源都下载完毕之后才能展示,所以在HTTP1.0中,客户端需要中断当前的连接,然后重新建立新的连接去请求资源。这会额外耗费多余的资源和时间。

在HTTP1.1中,引入了持久连接和管道的概念,这样不用每次请求都去重新开启和新建连接,HTTP默认底层的TCP连接是open的,除非手动告诉它要去关闭。在这种情况下,客户端可以使用同一个连接去和server进行交互,从而极大的提升HTTP的效率。

虽然在HTTP1.1中可以使用同一个连接进行数据传输了,但是对于这个连接来说,其中的请求是一一响应的,他们是有顺序的。如果最前面的请求被阻塞了,后面的请求也得不到响应。这种情况被称为head-of-line (HOL) blocking。

为了解决这个问题,可以在client和server端建立多个连接,这样就可以利用多个connection并行进行数据的传输,从而提升传输效率。

但是这样的缺点就是新建连接会消耗太多的资源,并且客户端和服务器端的连接个数也是有限的。

所以HTTP/2出现了。

在 HTTP/2 中,数据以二进制的的格式进行传输,其本身是将原始请求的切分成为更小的信息包,从而大大增加了数据传输的灵活性。

HTTP1.1需要建立多个TCP连接从而解决并行传输的问题,但是在HTTP/2 中只需要建立一个连接就够了。在这个连接中可以传输多个数据流,每个数据流中又包含多个message包,每个message又被切分为多个数据frame。

这些数据frame可以在传输期间交换位置,然后在接收的另一端重新组装。 交错的请求和响应可以并行运行,从而不会阻塞它们背后的消息,这个过程被称为多路复用。 多路复用的机制使得一个消息不用等待另外一个消息发送完成,从而解决了HTTP/1.1 中的队头阻塞问题。 这也意味着服务器和客户端可以发送并发请求和响应,从而实现更好的控制和更有效的连接管理。

多路复用虽然构建了多个消息流,但是只占用了一个TCP连接,从而减少整个网络的内存和处理占用空间,得到更好的网络和带宽利用率,从而降低整体运营成本。

单个TCP连接还提高了HTTPS协议的性能,因为客户端和服务器可以为多个请求/响应重用相同的安全会话。 在HTTPS中,在TLS或SSL握手期间,双方在整个会话期间使用单个密钥。 如果连接中断从新开始新的会话,则需要新生成的密钥进行进一步通信。 因此,维护单个连接可以大大减少HTTPS所需的资源。

请注意,尽管HTTP/2规范并未强制要求使用TLS,但许多主要浏览器仅支持带有HTTPS的HTTP/2。

流优先级

虽然HTTP/2解决了多个数据frame通常传输的问题,但是对于同一个资源来说,必须要等到的所有的数据frame都接受完毕之后才能展示,如果我们想优先展示某个资源该怎么做呢?

HTTP/2提供了流优先级的解决方案。

在客户端向服务器端发送消息的时候,消息会以流的形式在连接中传输,这些流可以分配1到256之间的权重来确定其请求的响应的优先级。 数字越大表示优先级越高。 除此之外,客户端还通过指定它所依赖的流的ID来说明每个流对另一个流的依赖关系。 如果省略父标识符,则认为该流依赖于根流。

服务器会使用流中的ID构建一个依赖树,从而确定其相应的顺序。

应用程序的开发人员可以根据需要自行设置请求的优先级,比如在网页中提供低分辨率的缩略图的同时提供低优先级的高分辨率的图像。通过为资源分配不同的优先级,开发人员能够更好地控制网页渲染。

缓冲区溢出处理

不管是哪种协议,客户端和服务器端在接收数据的时候都有一个缓冲区来临时存储暂时处理不了的数据,但是缓冲区的大小是有限制的,所以有可能会出现缓冲区溢出的情况,比如客户端向服务器端上传一个大的图片,就有可能导致服务器端的缓冲区溢出,从而导致一些额外的数据包丢失。

为了避免缓冲区溢出,各个HTTP协议都提供了一定的解决办法。

在HTTP1.1中,流量的控制依赖的是底层TCP协议,在客户端和服务器端建立连接的时候,会使用系统默认的设置来建立缓冲区。在数据进行通信的时候,会告诉对方它的接收窗口的大小,这个接收窗口就是缓冲区中剩余的可用空间。如果接收窗口大小为零,则说明接收方缓冲区已满,则发送方将不再发送数据,直到客户端清除其内部缓冲区,然后请求恢复数据传输。

因为HTTP1.1使用多个TCP连接,多以可以对每一个TCP连接进行单独的流量控制。但是HTTP2使用的是多路复用的模式,所以它的流量控制方式和HTTP1.1是不同的。

HTTP2是通过客户端和服务器端的应用中进行缓冲区大小消息的传输,是通过在应用层层面控制数据流,所以各个应用端可以自行控制流量的大小,从而实现更高的连接效率。

HTTP/2提供了更详细的控制级别,从而开启了更大优化的可能性。

预测资源请求

在一个典型的web应用中,当客户端发动一个GET请求到服务器端的时候,通常客户端会发现所以需要的不止一个资源,还可能包含了CSS或者其他JS等资源。但是客户端只有在首次获取到服务器端的响应时候才能真正确认到底需要哪些资源。然后需要额外请求这些资源以完成整个请求。但是这些额外的请求最终会增加连接加载时间。

那么有没有可能服务器在客户端请求之前将资源发送给客户端呢?我们看下HTTP1.1和HTTP2是怎么做的。

在HTTP1.1中,主要使用的资源内联的方式,比如将客户端所需要的CSS或者JS资源包含在服务器最初发送的HTML文档中,也就是做内联操作,从而减少客户端必须发送的请求总数。

但是这样的解决方案也有问题。因为一般来说资源内联一般是针对较小的资源来说的,如果资源文件太大的话,就会大大的增加HTML文件的大小,从而抵消减少连接提升的速度优势。

另外如果资源被放在HTML中,那么客户端就没有可能对这些资源进行缓存,从而影响整体的效率。

在HTTP/2中使用的是服务器推送。因为HTTP/2在同一个连接中可以发送多个stream,所以服务器可以将资源与请求的HTML页面一起发送到客户端,在客户端请求之前提供资源。 这个过程被称为服务器推送。

这样就可以在不用新开连接的同时,实现HTML文档和资源的分离和同时推送。

但是在HTTP/2中,服务器推送是由应用程序来控制的,所以控制起来会比较复杂。我们会在后续的文章中详细讲解HTTP/2的服务器推送。

压缩

通常为了减少数据在网络中的传输,我们需要对数据进行压缩,接下来我们看一下在HTTP1.1和HTTP2中都是怎么做的。

在HTTP1.1中,通常使用gzip对HTTP中的消息进行压缩,主要是针对CSS文件和javascript文件,但是HTTP的消息头还是由纯文本来发送的,另外由于cookie的使用,导致HTTP消息头的大小会越来越大,从而对程序的性能造成一定的影响。

在HTTP/2中,使用的算法是HPACK算法,同时可以对HTTP头和数据分别进行压缩,从而大大减少其大小。

总结

HTTP/2在HTTP1.1的基础上进行了更加细粒度的优化控制,并提供了包括多路复用、流优先级、流量控制、服务器推送和压缩等高级特性。非常强大。希望大家能够喜欢。

本文已收录于 www.flydean.com/02-http1-1-…

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

本文转载自: 掘金

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

使用Java8改造出来的模板方法真的是yyds

发表于 2021-08-23

GitHub 21.3k Star 的Java工程师成神之路,不来了解一下吗!

GitHub 21.3k Star 的Java工程师成神之路,真的不来了解一下吗!

我们在日常开发中,经常会遇到类似的场景:当要做一件事儿的时候,这件事儿的步骤是固定好的,但是每一个步骤的具体实现方式是不一定的。

通常,遇到这种情况,我们会把所有要做的事儿抽象到一个抽象类中,并在该类中定义一个模板方法。这就是所谓的模板方法模式。

以前的模板方法

在我之前的一篇《设计模式——模板方法设计模式》文章中举过一个例子:

当我们去银行的营业厅办理业务需要以下步骤:1.取号、2.办业务、3.评价。

三个步骤中取号和评价都是固定的流程,每个人要做的事儿都是一样的。但是办业务这个步骤根据每个人要办的事情不同所以需要有不同的实现。

我们可以将整个办业务这件事儿封装成一个抽象类:

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
csharp复制代码/**
* 模板方法设计模式的抽象类
* @author hollis
*/
public abstract class AbstractBusinessHandler {
/**
* 模板方法
*/
public final void execute(){
getNumber();
handle();
judge();
}
/**
* 取号
* @return
*/
private void getNumber(){
System.out.println("number-00" + RandomUtils.nextInt());
}
/**
* 办理业务
*/
public abstract void handle(); //抽象的办理业务方法,由子类实现
/**
* 评价
*/
private void judge(){
System.out.println("give a praised");
}
}

我们在类中定义了一个execute类,这个类编排了getNumber、handle和judge三个方法。这就是一个模板方法。

其中getNumber和judge都有通用的实现,只有handle方法是个抽象的,需要子类根据实际要办的业务的内容去重写。

有了这个抽象类和模板方法,当我们想要实现一个”存钱业务”的时候,只需要继承该AbstractBusinessHandeler并且重写handle方法即可:

1
2
3
4
5
6
scala复制代码public class SaveMoneyHandler extends AbstractBusinessHandeler {
@Override
public void handle() {
System.out.println("save 1000");
}
}

这样,我们在执行存钱的业务逻辑的时候,只需要调用 SaveMoneyHandler的execute方法即可:

1
2
3
4
typescript复制代码public static void main(String []args){
SaveMoneyHandler saveMoneyHandler = new SaveMoneyHandler();
saveMoneyHandler.execute();
}

输出结果:

1
2
3
css复制代码number-00958442164
save 1000
give a praised

以上,就是一个简单的模板方法的实现。通过使用模板方法,可以帮助我们很大程度的复用代码。

因为我们要在银行办理很多业务,所以可能需要定义很多的实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scala复制代码//取钱业务的实现类
public class DrawMoneyHandler extends AbstractBusinessHandeler {
@Override
public void handle() {
System.out.println("draw 1000");
}
}

//理财业务的实现类
public class MoneyManageHandler extends AbstractBusinessHandeler{
@Override
public void handle() {
System.out.println("money manage");
}
}

一直以来,开发者们在使用模板方法的时候基本都是像上面这个例子一样:需要准备一个抽象类,将部分逻辑以具体方法以及具体构造函数的形式实现,然后声明一些抽象方法来让子类实现剩余的逻辑。不同的子类可以以不同的方式实现这些抽象方法,从而对剩余的逻辑有不同的实现。

但是,有了Java 8以后,模板方法有了另外一种实现方式,不需要定义特别多的实现类了。

Java 8 的函数式编程

2014年,Oracle发布了 Java 8,在Java 8中最大的新特性就是提供了对函数式编程的支持。

Java 8在java.util.function下面增加增加一系列的函数接口。其中主要有Consumer、Supplier、Predicate、Function等。

本文主要想要介绍一下Supplier和Consumer这两个,使用者两个接口,可以帮我们很好的改造模板方法。这里只是简单介绍下他们的用法,并不会深入展开,如果大家想要学习更多用法,可以自行google一下。

Supplier

Supplier是一个供给型的接口,简单点说,这就是一个返回某些值的方法。

最简单的一个Supplier就是下面这段代码:

1
2
3
typescript复制代码public List<String> getList() {
return new ArrayList();
}

使用Supplier表示就是:

1
ini复制代码Supplier<List<String>> listSupplier = ArrayList::new;

Consumer

Consumer 接口消费型接口,简单点说,这就是一个使用某些值(如方法参数)并对其进行操作的方法。

最简单的一个Consumer就是下面这段代码:

1
2
3
typescript复制代码public void sum(String a1) {
System.out.println(a1);
}

使用Consumer表示就是:

1
ini复制代码Consumer<String> printConsumer = a1 -> System.out.println(a1);

Consumer的用法,最见的的例子就是是Stream.forEach(Consumer)这样的用法,

它接受一个Consumer,该Consumer消费正在迭代的流中的元素,并对每个元素执行一些操作,比如打印:

1
2
rust复制代码Consumer<String> stringConsumer = (s) -> System.out.println(s.length());
Arrays.asList("ab", "abc", "a", "abcd").stream().forEach(stringConsumer);

Java 8以后的模板方法

在介绍过了Java 8中的Consumer、Supplier之后,我们来看下怎么改造之前我们介绍过的模板方法。

首先,我们定义一个BankBusinessHandler类,并且重新定义一个execute方法,这个方法有一个入参,是Consumer类型的,然后移除handle方法,重新编排后的模板方法内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
csharp复制代码/**
* @author Hollis
*/
public class BankBusinessHandler {
private void execute(Consumer<BigDecimal> consumer) {
getNumber();

consumer.accept(null);

judge();
}

private void getNumber() {
System.out.println("number-00" + RandomUtils.nextInt());
}

private void judge() {
System.out.println("give a praised");
}
}

我们实现的模板方法execute中,编排了getNumber、judge以及consumer.accept,这里面consumer.accept就是具体的业务逻辑,可能是存钱、取钱、理财等。需要由其他方法调用execute的时候传入。

这时候,我们想要实现”存钱”业务的时候,需要BankBusinessHandler类中增加以下方法:

1
2
3
4
5
6
7
8
9
csharp复制代码/**
* @author Hollis
*/
public class BankBusinessHandler {

public void save(BigDecimal amount) {
execute(a -> System.out.println("save " + amount));
}
}

在save方法中,调用execute方法,并且在入参处传入一个实现了”存钱”的业务逻辑的Comsumer。

这样,我们在执行存钱的业务逻辑的时候,只需要调用 BankBusinessHandler的save方法即可:

1
2
3
4
java复制代码public static void main(String[] args) throws {
BankBusinessHandler businessHandler = new BankBusinessHandler();
businessHandler.save(new BigDecimal("1000"));
}

输出结果:

1
2
3
css复制代码number-001736151440
save1000
give a praised

如上,当我们想要实现取钱、理财等业务逻辑的时候,和存钱类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
csharp复制代码/**
* @author Hollis
*/
public class BankBusinessHandler {

public void save(BigDecimal amount) {
execute(a -> System.out.println("save " + amount));
}

public void draw(BigDecimal amount) {
execute(a -> System.out.println("draw " + amount));
}

public void moneyManage(BigDecimal amount) {
execute(a -> System.out.println("draw " + amount));
}
}

可以看到,通过使用Java 8中的Comsumer,我们把模板方法改造了,改造之后不再需要抽象类、抽象方法,也不再需要为每一个业务都创建一个实现类了。我们可以把所有的业务逻辑内聚在同一个业务类中。这样非常方便这段代码的后期运维。

前面介绍如何使用Consumer进行改造模板方法,那么Supplier有什么用呢?

我们的例子中,在取号、办业务、评价这三个步骤中,办业务是需要根据业务情况进行定制的,所以,我们在模板方法中,把办业务这个作为扩展点开放给外部。

有这样一种情况,那就是现在我们办业务的时候,取号的方式也不一样,可能是到银行网点取号、在网上取号或者银行客户经理预约的无需取号等。

无论取号的方式如何,最终结果都是取一个号;而取到的号的种类不同,可能接收到的具体服务也不同,比如vip号会到VIP柜台办理业务等。

想要实现这样的业务逻辑,就需要使用到Supplier,Supplier是一个”供给者”,他可以用来定制”取号逻辑”。

首先,我们需要改造下模板方法:

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
typescript复制代码/**
* 模板方法
*/
protected void execute(Supplier<String> supplier, Consumer<BigDecimal> consumer) {

String number = supplier.get();
System.out.println(number);


if (number.startsWith("vip")) {
//Vip号分配到VIP柜台
System.out.println("Assign To Vip Counter");
}
else if (number.startsWith("reservation")) {
//预约号分配到专属客户经理
System.out.println("Assign To Exclusive Customer Manager");
}else{
//默认分配到普通柜台
System.out.println("Assign To Usual Manager");
}

consumer.accept(null);

judge();
}

经过改造,execute的入参增加了一个supplier,这个supplier可以提供一个号码。至于如何取号的,交给调用execute的方法来执行。

之后,我们可以定义多个存钱方法,分别是Vip存钱、预约存钱和普通存钱:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
csharp复制代码public class BankBusinessHandler extends AbstractBusinessHandler {

public void saveVip(BigDecimal amount) {
execute(() -> "vipNumber-00" + RandomUtils.nextInt(), a -> System.out.println("save " + amount));
}

public void save(BigDecimal amount) {
execute(() -> "number-00" + RandomUtils.nextInt(), a -> System.out.println("save " + amount));
}

public void saveReservation(BigDecimal amount) {
execute(() -> "reservationNumber-00" + RandomUtils.nextInt(), a -> System.out.println("save " + amount));
}

}

在多个不同的存钱方法中,实现不同的取号逻辑,把取号逻辑封装在supplier中,然后传入execute方法即可。

测试代码如下:

1
2
ini复制代码BankBusinessHandler businessHandler = new BankBusinessHandler();
businessHandler.saveVip(new BigDecimal("1000"));

输出结果:

1
2
3
4
css复制代码vipNumber-001638110566
Assign To Vip Counter
save 1000
give a praised

以上,我们就是用Comsumer和Supplier改造了模板方法模式。

使用Java 8对模板方法进行改造之后,可以进一步的减少代码量,至少可少创建很多实现类,大大的减少重复代码,提升可维护性。

当然,这种做法也不是十全十美的,有一个小小的缺点,那就是理解成本稍微高一点,对于那些对函数式编程不太熟悉的开发者来说, 上手成本稍微高了一些。。。

总结

以上,我们介绍了什么是模板方法模式,以及如何使用Comsumer和Supplier改造模板方法模式。

这样的做法是我们日常开发中经常会用到的,其实,我觉得本文中的例子并不是完完全全能表达出来我想表达的意思,但是我们的真实业务中的逻辑讲起来又比较复杂。

所以,这就需要大家能够多多理解并且实践一下。如果你代码中用到过模板方法模式,那一定是可以通过本文中的方法进行改造的。

如果你还没用过模板方法模式,那说明你的应用中一定有很多重复代码,那就赶紧用起来。

作为一个开发工程师,我们要尽最大努力的消灭应用中的重复代码,功在当代,利在千秋!

关于作者:Hollis,一个对Coding有着独特追求的人,阿里巴巴技术专家,《程序员的三门课》联合作者,《Java工程师成神之路》系列文章作者。

关注公众号【Hollis】,后台回复”成神导图”可以咯领取Java工程师进阶思维导图。

本文转载自: 掘金

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

「雕虫小技」基于日志的链路追踪实现方案

发表于 2021-08-23

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

什么是链路追踪

就像一份外卖订单,从用户点单 - 商家接单 - 商家制作 - 外卖员取餐 - 送餐一样,每一个业务都有其特定的执行流程,把它细分到代码、应用层级,就可以称为执行链路,当前请求/业务到底是如何一步一步执行到结束的,它在哪一个阶段发生异常被迫终止的,这都是程序员想要知道的,因为这些讯息可以帮助我们定位并解决问题。

这种业务执行链路在数据层面肯定会有所体现,但是它可能还不够具体和详细,因此大部分链路追踪的实现方式是基于日志来体现的,从程序的入口到最后一层出口,让它中间的每一行日志都有其特定的唯一标识,将之串联起来,就成了一条完整的链路。

链路追踪的使用场景

我们可以基于链路追踪的能力从不同维度做各种事情,这就要考虑各位读者的需要和想象力了😜

image-20210818082741431.png

应用链路

应用链路指的是单体应用内的链路追踪,它最大的用处便是提高大量日志下问题定位的效率。

比如在电商业务系统中,商品处理细节非常繁琐,由于业务的特殊性,某一个商品变更MQ会同时触发多个类似业务线的处理,这几条业务线同时收到消息,共同处理,在毫秒间的差距里可能打出上千条日志,除非事先就有规划的设计日志打印的规则,否则想在上千条日志里找到某一条线路的完整执行状态,简直就是匪夷所思,此时,应用链路日志就派上了用处,我只需要从入口处分析出TraceId,然后二次搜索即可。

亦或是和用户直接打交道的网关系统,需要分析出用户当前登录状态下的所有行为,我们只需要把SessionId或者UserId打印在日志内,然后通过搜索即可得到全部的信息,拿到这些信息我们甚至可以反推算出用户行为进行深度分析,或者存入流量系统用作压测流量回放等等。

image-20210818084229606.png

比如基于上图中的日志就可以看出,该用户使用APP版本为8.1.5,先进行了登录动作,再查看个人信息,翻阅了APP首页,继而进入爆款专区,查看了两个商品,然后执行了加入购物车操作,整个用户动作基于用户SessionId进行筛选,时间进行排序,非常的清晰。

全链路

全链路指的是在微服务架构下,用户某次请求从APP/H5到网关、中台、底层服务的全部链路。

全链路追踪的价值不仅仅在于协助问题定位那么简单,它还能做这些事:

  • 基于全链路日志分析,梳理出整个公司架构体系下的调用链路

中小公司的应用可能不多,但稍大的公司的架构体系可能是上百个系统互相交织在一起,无比复杂

  • 已知调用链路后,还可以分析出整个请求的耗时阶段,进行性能优化等等
  • 统计应用的流量占比,即分析出当前系统对外提供的流量都消耗在哪些系统上
  • ….

总之,数据为王,只要能够获取全链路的链路追踪数据就可以不断挖掘出内在的价值,优化整个架构体系。

系统级链路追踪的实现方案

上文讲述了什么是链路追踪,以及它蕴含的巨大业务价值,但相反的是,它的实现可能无比简单,以一个网关系统实现单应用内链路追踪为例。

AOP + ThreadLocal

AOP:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。

ThreadLocal:ThreadLocal提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。

我们刚提到了本例是基于一个网关系统而言,因此使用拦截器即可完美实现AOP的效果,核心代码如下:

1
2
3
4
5
6
7
8
9
10
java复制代码@Service
public class LogService {
private static final Logger logger = LoggerFactory.getLogger(LogService.class);
public Map<String, String> handle() {
Map<String, String> map = new HashMap<>();
map.put("Name", "Kerwin");
logger.info("LogService.handle, TraceId:{}", ThreadLocalDemo.getLocal());
return map;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@RestController
public class LogDemo {

@Resource
LogService logService;

@RequestMapping("/test")
@ResponseBody
public Map<String, String> test() {
return logService.handle();
}
}

此时通过PostMan/浏览器发起请求,得到的日志效果为:

1
bash复制代码2021-08-16 01:32:39  INFO 13956 --- [nio-8080-exec-7] com.boot.service.LogService              27 : LogService.handle, TraceId:null

实现拦截器,具体代码为:

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
java复制代码@Component
public class MvcConfigurer implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
ThreadLocalDemo.setLocal(UUID.randomUUID().toString());
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
ThreadLocalDemo.remove();
}

@Configuration
class InterceptorConfig implements WebMvcConfigurer {

// 拦截全部入口
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration registration = registry.addInterceptor(new MvcConfigurer());
registration.addPathPatterns("/**");
}
}
}

工具类核心代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public class ThreadLocalDemo {

private static ThreadLocal<String> local = new ThreadLocal<>();

public static void setLocal(String demo) {
local.set(demo);
}

public static String getLocal() {
return local.get();
}

public static void remove() {
local.remove();
}
}

再次发起请求,观察日志为:

1
bash复制代码2021-08-16 01:40:26  INFO 16564 --- [nio-8080-exec-1] com.boot.service.LogService              27 : LogService.handle, TraceId:72df9e08-ffcf-465b-81cd-002f6d06b148

如文中所示,一个非常简单的网关系统链路追踪就完成了,如果换为其他底层系统,只需要在对外接口进行切面处理即可,效果是类似的。

但是上述方案存在几个问题,例如:

  1. 此方案需要在日志打印处额外打印TraceId,增加编码成本
  2. 硬编码方式导致拓展性极差

MDC 方案

其实在思考方案一的时候就能发现到,我们是把唯一标识(TraceId)存放在了系统内部然后用日志Logger打印出来,那么我们能不能直接拿到每一个Logger里面的上下文呢,要知道日志框架同样是服务于每一个线程的,于是一个新的概念就诞生了,即MDC。

MDC(Mapped Diagnostic Contexts):映射调试上下文,主要用在做日志链路跟踪时,动态配置用户自定义的一些信息,比如RequestId、TraceId等等。

鉴于在工业级使用的时候我们都会依赖Slf4j,因此本例也是基于此来实现的,关于日志框架的细节,请见文章:动态日志级别:小功能,大用处。

还是刚才的那一段代码,先来看Controller层和Service层。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@RestController
public class LogDemo {

@Resource
LogService logService;

@RequestMapping("/test")
@ResponseBody
public Map<String, String> test() {
return logService.handle();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Service
public class LogService {

private static final Logger logger = LoggerFactory.getLogger(LogService.class);

public Map<String, String> handle() {
Map<String, String> map = new HashMap<>();
map.put("Name", "Kerwin");
logger.info("LogService.handle");
return map;
}
}

注意到几点变化:

  1. Controller层直接依赖Service层,没有任何变化
  2. Service层去掉了显示日志打印

拦截器代码由ThreadLocal调整为MDC,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Component
public class MvcConfigurer implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
MDC.put("TraceId", UUID.randomUUID().toString());
return true;
}

// ....

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
MDC.clear();
}
}

日志的输出格式增加相关内容,如下:

1
2
3
4
5
xml复制代码<!--原-->
<Property name="PATTERN_FORMAT">%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%t] %class{36} (%L) %M - %msg%xEx%n</Property>

<!--新-->
<Property name="PATTERN_FORMAT">[%X{TraceId}] %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%t] %class{36} (%L) %M - %msg%xEx%n</Property>

结果输出如下:

1
bash复制代码[1e6f506b-17ec-452f-a727-a42ebb03e154] 2021-08-16 02:01:58  INFO 17936 --- [nio-8080-exec-1] com.boot.service.LogService              26 : LogService.handle

看到这里的小伙伴可能会猜测所谓的MDC只不过是日志框架自己的ThreadLocal罢了。

我想说的是确实差不多,所有技术深入到原理层之后,其实也就那样,因为我们使用的是Slf4j的门面,它的核心类是:MDC,实际工作者是:MDCAdapter,根据所绑定的日志实现框架不同有所区别,比如以Logback为例,与之对应的实现是LogbackMDCAdapter,底层使用的数据结构是:

1
java复制代码final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal<Map<String, String>>();

与Logback不同的是Log4j2 MDC的实现类是:Log4jMDCAdapter,它的底层实现会相对复杂一些,使用了ThreadContext类屏蔽了较为复杂的设计,但本质上依然是ThreadLocal的变形罢了,值得注意的是,Log4j2的MDC方案支持父子线程的参数传递,配置方式是:

1
2
3
java复制代码static {
System.setProperty("log4j2.isThreadContextMapInheritable", "true");
}

当开启了isThreadContextMapInheritable能力后,其底层实现会有稍许不同,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// isThreadContextMapInheritable true 即入参为true
static ThreadLocal<Map<String, String>> createThreadLocalMap(final boolean isMapEnabled) {
if (inheritableMap) {
return new InheritableThreadLocal<Map<String, String>>() {
@Override
protected Map<String, String> childValue(final Map<String, String> parentValue) {
return parentValue != null && isMapEnabled //
? Collections.unmodifiableMap(new HashMap<>(parentValue)) //
: null;
}
};
}
// if not inheritable, return plain ThreadLocal with null as initial value
return new ThreadLocal<>();
}

当开启了这项能力后,MDC将会创建InheritableThreadLocal进行临时变量的存储,那么它有什么能力呢?

InheritableThreadLocal使父线程生成的变量可以传递到子线程中进行使用。

我们来测试一下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码public class LogDemo {

static {
System.setProperty("log4j2.isThreadContextMapInheritable", "true");
}

private static Logger logger = LoggerFactory.getLogger(LogDemo.class);

public static void main(String[] args) throws InterruptedException {
MDC.put("threadId", "Kerwin~");

logger.info("Just a Demo~");
new Thread(() -> logger.info("Inheritable Test...")).start();
Thread.sleep(1000);
}
}

// 输出结果为:
[Kerwin~] 2021-08-16 03:39:38.764 INFO [main] LogDemo (24) main - Just a Demo~
[Kerwin~] 2021-08-16 03:39:38.769 INFO [Thread-1] LogDemo (25) lambda$main$0 - Inheritable Test...

全链路追踪的实现方案

在我们了解完系统级链路追踪方案之后,全链路追踪核心要解决的问题大家可能已经有了答案,即:链路的延续(TraceId的传递)。

首先明确一点:

  • 服务间如果想传递TraceId,一定需要进行数据传输

那么为了实现数据传输,同时避免硬编码,架构师们一定会在类似拦截器一样的位置进行设计和编码,避免影响业务代码。

如果我们仅仅是希望传递TraceId,那么在RPC层框架的调用入口处增加隐式参数:TraceId即可,然后底层进行接受继续透传到下一个系统,就完成了最简单的链路追踪。

如果想要实现更加强大的分析能力,我们就需要收集更多的信息,可能需要引入如:Zipkin或阿里的Tracing这样的分析工具,依然是在上文提到的位置进行拦截,把详细的数据传输给它们而已,本质没有任何变化。

以Zipkin为例,我们来梳理一下全链路的大致工作流程,:

1
2
bash复制代码网关请求入口创建TraceId --------> A服务的RPC接口 --------> B服务的RPC接口
TraceId上报 TraceId上报

各节点数据都上报至Zipkin之后,由它进行数据汇聚和专项分析,再提供统一的入口来查询日志,下图是它的内部工作原理:

图片来源:ZipKin官方网站

image-20210818085945978.png

有了类似的中间件工具,我们只需要在自己的框架接入即可,比如 SpringBoot 2.0 版本之后,Zipkin已经提供了专门的Starter,可以非常方便的接入并使用,大家有兴趣的可以自行尝试一下。

总结

本文介绍了基于日志方式的链路追踪原理及实现,它的设计其实并不复杂,关键在于如何更灵活的应用,下面是几点小建议:

  1. 尽量让系统具备链路追踪的能力,配置起来非常简单,关键时候可以起到很大的用处。
  2. 尽量使用日志框架的MDC能力,避免重复造轮子。
  3. 全链路日志尽量使用现有工具和平台,可以提供更强大的分析能力。

如果觉得这篇内容对你有帮助的话:

  1. 当然要点赞支持一下啦~
  2. 另外,可以搜索并关注公众号「是Kerwin啊」,一起在技术的路上走下去吧~ 😋

本文转载自: 掘金

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

docker入门:极简方式发布springboot

发表于 2021-08-23

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

由于容器化的引入,使用docker发布项目是必须技能,下文带你掌握如果用docker分别发布前后端项目。本文以最简化的配置方式举例,保证可正常运行。

1.发布springboot项目

1.编写Dockerfile文件

下文以eureka项目举例,可以根据配置修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码# Docker image for springboot file run
# VERSION 0.0.1
# Author: eangulee
# 基础镜像使用java
FROM java:8
# VOLUME 指定了临时文件目录为/tmp。
# 其效果是在主机 /var/lib/docker 目录下创建了一个临时文件,并链接到容器的/tmp
VOLUME /tmp
# eureka-0.0.1-SNAPSHOT.jar为jar名称 将jar包添加到容器中并更名为eureka.jar 注意要有/
ADD eureka-0.0.1-SNAPSHOT.jar /eureka.jar
# 运行jar包 这里的eureka.jar与上文eureka.jar对应
RUN bash -c 'touch /eureka.jar'
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/eureka.jar"]

解释下这个配置文件:

VOLUME 指定了临时文件目录为/tmp。其效果是在主机 /var/lib/docker 目录下创建了一个临时文件,并链接到容器的/tmp。该步骤是可选的,如果涉及到文件系统的应用就很有必要了。/tmp目录用来持久化到 Docker 数据文件夹,因为 Spring Boot 使用的内嵌 Tomcat 容器默认使用/tmp作为工作目录

项目的 jar 文件作为 “eureka.jar” 添加到容器的

ENTRYPOINT 执行项目 app.jar。为了缩短 Tomcat 启动时间,添加一个系统属性指向 “/dev/./urandom” 作为 Entropy Source

如果是第一次打包,它会自动下载java 8的镜像作为基础镜像,以后再制作镜像的时候就不会再下载了。

2.部署文件

在服务器新建一个docker文件夹,将maven打包好的jar包和Dockerfile文件复制到服务器的docker文件夹下

3.制作镜像

1
2
bash复制代码cd /root/docker
docker build -t eureka . //注意此处的空格与. eureka为镜像别名!!!!

一定要注意”. “ .后面有个空格!

image.png

如果出现错误 Step 3/5 : ADD eureka-0.0.1-SNAPSHOT.jar app.jarlstat eureka-0.0.1-SNAPSHOT.jar: no such file or directory

为ADD demo-0.0.1-SNAPSHOT.jar /eureka.jar处配置问题,请检查是否指定了Application启动文件。

4.查看镜像

1
复制代码docker images

见下图,可以看到本地已经有docker镜像了。

5.启动容器

  • 第一个8761:为你正在操作虚拟机对外的端口 。
  • 第二个8761:为docker容器对虚拟机提供的接口 。
  • –net=host:以本机ip作为容器地址。
  • eureka:为镜像名称。
1
ini复制代码docker run -d -p 8761:8761 --net =host eureka

注意这里有个坑,如果是使用springboot Netflix,在服务间调用时,会从eureka拉取服务的ip地址。但是不加–net =host,会拉取docker的虚拟出的地址,当服务间访问时会访问虚拟地址而不是需要访问的服务器地址,所以这里楼主使用服务器地址作为docker的地址。如果有更好办法请联系楼主。

接下来就可以进入容器中,对容器进行操作了。

1
arduino复制代码docker run -it eureka  /bin/bash

本文转载自: 掘金

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

Java之多线程

发表于 2021-08-23

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

Java 多线程编程

Java 给多线程编程提供了内置的支持。 一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

多线程是多任务的一种特别的形式,但多线程使用了更小的资源开销。

这里定义和线程相关的另一个术语 - 进程:一个进程包括由操作系统分配的内存空间,包含一个或多个线程。一个线程不能独立的存在,它必须是进程的一部分。一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。

多线程能满足程序员编写高效率的程序来达到充分利用 CPU 的目的。

什么是Java多线程?

1、进程与线程

▶进程

  • 当一个程序被运行,就开启了一个进程, 比如启动了qq,word
  • 程序由指令和数据组成,指令要运行,数据要加载,指令被cpu加载运行,数据被加载到内存,指令运行时可由cpu调度硬盘、网络等设备

▶线程

  • 一个进程内可分为多个线程
  • 一个线程就是一个指令流,cpu调度的最小单位,由cpu一条一条执行指令

并行与并发

▶并发:单核cpu运行多线程时,时间片进行很快的切换。线程轮流执行cpu

▶并行:多核cpu运行 多线程时,真正的在同一时刻运行

创建多线程

继承 Thread 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typescript复制代码public class test0 {

public static void main(String[] args) {
Thread MyThread = new MyThread();
MyThread.start();
}
}

class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello myThread" + Thread.currentThread().getName());
}
}

实现 Runnable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typescript复制代码public class test0 {

public static void main(String[] args) {

MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
}
}

class MyRunnable implements Runnable{
@Override
public void run(){
System.out.println("hello myRunnable" + Thread.currentThread().getName());
}
}

暂停

Java中线程的暂停是调用java.lang.Thread类的sleep方法(注意是类方法)。该方法会使当前正在执行的线程暂停指定的时间,如果线程持有锁,sleep方法结束前并不会释放该锁。

Java示例:

1
2
3
4
5
6
7
8
9
10
11
csharp复制代码public class Main {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
try {
Thread.sleep(1000); //当前main线程暂停1000ms
} catch (InterruptedException e) {
}
}
}
}

上述代码中,当main线程调用Thread.sleep(1000)后,线程会被暂停,如果被interrupt,则会抛出InterruptedException异常。

互斥

Java中线程的共享互斥操作,会使用synchronized关键字。线程共享互斥的架构称为监视(monitor),而获取锁有时也称为“持有(own)监视”。

每个锁在同一时刻,只能由一个线程持有。

注意:synchronized方法或声明执行期间,如程序遇到任何异常或return,线程都会释放锁。

1、synchronized方法

1
2
3
4
arduino复制代码//synchronized实例方法
public synchronized void deposit(int m) {
System.out.print("This is synchronized method.");
}

注:synchronized实例方法采用this锁(即当前对象)去做线程的共享互斥。

1
2
3
4
arduino复制代码//synchronized类方法
public static synchronized void deposit(int m) {
System.out.print("This is synchronized static method.");
}

注:synchronized类方法采用类对象锁(即当前类的类对象)去做线程的共享互斥。如上述示例中,采用类.class(继承自java.lang.Class)作为锁。

2、synchronized声明

1
2
3
4
5
6
7
8
csharp复制代码public void deposit(int m) {
synchronized (this) {
System.out.print("This is synchronized statement with this lock.");
}
synchronized (Something.class) {
System.out.print("This is synchronized statement with class lock.");
}
}

注:synchronized声明可以采用任意锁,上述示例中,分别采用了对象锁(this)和类锁(something.class)

3、等待(wait)和通知(notify)

Object有两个很重要的接口:Object.wait()和Object.notify()

当在一个对象实例上调用了wait()方法后,当前线程就会在这个对象上等待。直到其他线程调用了这个对象的notify()方法或者notifyAll()方法。notifyAll()方法与notify()方法的区别是它会唤醒所有正在等待这个对象的线程,而notify()方法只会随机唤醒一个等待该对象的线程。

wait()、notify()和notifyAll()都需要在synchronized语句中使用

4、中断

java.lang.Thread类有一个interrupt方法,该方法直接对线程调用。当被interrupt的线程正在sleep或wait时,会抛出InterruptedException异常。

事实上,interrupt方法只是改变目标线程的中断状态(interrupt status),而那些会抛出InterruptedException异常的方法,如wait、sleep、join等,都是在方法内部不断地检查中断状态的值。

  • interrupt方法

Thread实例方法:必须由其它线程获取被调用线程的实例后,进行调用。实际上,只是改变了被调用线程的内部中断状态;

  • Thread.interrupted方法

Thread类方法:必须在当前执行线程内调用,该方法返回当前线程的内部中断状态,然后清除中断状态(置为false) ;

  • isInterrupted方法

Thread实例方法:用来检查指定线程的中断状态。当线程为中断状态时,会返回true;否则返回false。

本文转载自: 掘金

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

1…553554555…956

开发者博客

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