Elasticsearch 学习

February 27th, 2020 by JasonLe's Tech 390 views

elasticsearch 的层次结构和关系型数据库不一致,分为Index/【Type】/Document三个层级,对标Mysql就是:

ELK DB
Index Database
Type(已废弃) Table
Document Row
Column Filed
Schema Mapping
SQL DSL

在7.0以前,一个index会设置多个types,目前type已经废除,7.0后只允许创建一个type –> _doc

但是Document中es允许不同的结构,但是最好保持相同,这样有利于提高搜索效率。当我们插入一条数据后,如果没有指定_id的话,es会随机分配一个_id,如果强加指定的话,会按照该id存储Document。但是如果频繁对数据进行修改,随着插入数据的变多,自定义的_id会出现冲突的问题,可能在后期sharding出现查询缓慢的问题,需要额外注意。

至于移除type的原因主要是因为Lucene导致的,具体查看 https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html

分片可以分为主分片(primary key)和复制分片(replica shard)

主分片
每个文档都被分派到索引下的某个主分片内。
当索引创建完成时,主分片的数量就固定了。(这里存在一个问题,随着数量的增加,可能会出现不够的情况,最好保持一个shard<=30GB)
主分片的大小理论上是无限制的。
所有的写操作只能在主分片上完成后才能复制到其他分片上,写操作包括新建,索引,更新,删除。

复制分片
复制分片是主分片的副本,用以防止硬件故障导致的数据丢失。
复制分片可以提供读操作,比如搜索或从别的shared取回文档。
复制分片可以支持横向拓展。

Document 元数据,其中的_source是原JSON文件,_id 就是es中唯一标识的一个字段。

{
  "_index" : "movies",
  "_type" : "_doc",
  "_id" : "1163",
  "_version" : 1,
  "_seq_no" : 875,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "@version" : "1",
    "year" : 1994,
    "title" : "Mina Tannenbaum",
    "genre" : [
      "Drama"
    ],
    "id" : "1163"
  }
}

Document settings

{
  "movies" : {
    "settings" : {
      "index" : {
        "creation_date" : "1582618004949",
        "number_of_shards" : "1",
        "number_of_replicas" : "0",
        "uuid" : "JvyjYwMeTk6OmxhSRo-ocA",
        "version" : {
          "created" : "7060099"
        },
        "provided_name" : "movies"
      }
    }
  }
}

Document mappings

{
  "movies" : {
    "mappings" : {
      "properties" : {
        "@version" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        ......
    }
  }
}

从这个里面看到@version 字段 既可以全文匹配,也可以根据关键词匹配。

另外一个非常重要的功能就是 analyzer ,analyzer有现成的也有手写的,总的规则就是:

{
    "settings": {
        "analysis": {
            "char_filter": { ... custom character filters ... },//字符过滤器
            "tokenizer": { ... custom tokenizers ... },//分词器
            "filter": { ... custom token filters ... }, //词单元过滤器
            "analyzer":    { ...    custom analyzers      ... }
        }
    }
}

一个分词就是先对文档进行过滤(char_filter),然后进行分词(tokenizer),然后再对分词后的词进行过滤(filter),我们可以将这几个部分组装在analyzer中,然后放入setting,最后在mapping中引用my_analyzer即可。

{
    "settings": {
        "analysis": {
            "char_filter": {
                "&amp;amp;_to_and": {
                    "type": "mapping",
                    "mappings": [ "&amp;amp;=&amp;gt; and "]
            }},
            "filter": {
                "my_stopwords": {
                    "type": "stop",
                    "stopwords": [ "the", "a" ]
            }},
            "analyzer": {
                "my_analyzer": {
                    "type": "custom",
                    "char_filter": [ "html_strip", "&amp;amp;_to_and" ],
                    "tokenizer": "standard",
                    "filter": [ "lowercase", "my_stopwords" ]
            }}
}}}

CRUD就不用说了,除了可以使用Postman进行接口调试,也可以使用Kibana的Dev Tool

 

https://www.elastic.co/guide/index.html
https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html

正则总结

December 20th, 2019 by JasonLe's Tech 323 views

如果需要匹配的字符串含有特殊字符,那就需要用 \转义。比如 a&b,在用正则表达式匹配时,需要使用 a\&b,又由于在 Java 字符串中,\ 也是特殊字符,它也需要转义,所以 a\&b 对应的 Java 字符串是 a\\&b,它是用来匹配 a&b 的。

System.out.println("a&b".matches("a\\&b")); // 输出为 true

\d\d 就能匹配两个数字,\d\d\d 能匹配三个数字,需要匹配几个数字就写几次就行了。

System.out.println("1".matches("\\d\\d")); // 输出为 false
System.out.println("11".matches("\\d\\d")); // 输出为 true
System.out.println("111".matches("\\d\\d")); // 输出为 false

在 \d 后面打上花括号 {},{n} 表示匹配 n 次。\d{10000} 就表示匹配 10000 个数字。如果要匹配 n ~ m 次,用 {n,m} 即可,如果要匹配至少 n 次,用 {n,} 即可。需要注意 , 后不能有空格。

System.out.println("1".matches("\\d{1,2}")); // 输出为 true
System.out.println("12".matches("\\d{1,2}")); // 输出为 true
System.out.println("123".matches("\\d{1,2}")); // 输出为 false
System.out.println("123".matches("\\d{2,}")); // 输出为 true

正则的基础规则中,除了 \d,还有 \w和\s,w 是 word 的简写,表示匹配一个常用字符,包括字母、数字、下划线。s 是 space 的简写,表示匹配一个空格,包括三种:空格键打出来的空格/Tab 键打出来的空格/回车键打出来的空格。

System.out.println("LeetCode_666".matches("\\w{12}")); // 输出为 true
System.out.println("\t \n".matches("\\s{3}")); // 输出为 true
System.out.println("Leet\tCode 666".matches("\\w{4}\\s\\w{4}\\s\\d{3}")); // 输出为 true

将字母换成大写,就表示相反的意思。用 \d 你可以匹配一个数字,\D 则表示匹配一个非数字。类似地,\W 可以匹配 \w 不能匹配的字符,\S 可以匹配 \s 不能匹配的字符。

System.out.println("a".matches("\\d")); // 输出为 false
System.out.println("1".matches("\\d")); // 输出为 true
System.out.println("a".matches("\\D")); // 输出为 true
System.out.println("1".matches("\\D")); // 输出为 false

我们对某些位置的字符没有要求,仅需要占个位置即可。这时候我们就可以用 . 字符。我们对匹配的次数没有要求,匹配任意次均可,这时,我们就可以用 * 字符。出现了 0 次,* 是指 可以匹配任意次,包括 0 次。也就是说,* 等价于 {0,}

System.out.println("1".matches("\\d*")); // 输出为 true
System.out.println("123".matches("\\d*")); // 输出为 true
System.out.println("".matches("\\d*")); // 输出为 true

可以用 + 匹配,+ 表示 至少匹配一次。它等价于 {1,}

System.out.println("1".matches("\\d+")); // 输出为 true
System.out.println("123".matches("\\d+")); // 输出为 true
System.out.println("".matches("\\d+")); // 输出为 false

如果某个字符要么匹配 0 次,要么匹配 1 次,我们就可以用 ? 匹配。它等价于 {0,1}

如果我们规定电话号码不能以 0 开头,正则匹配规则是 [123456789]\d{10}。

System.out.println("1".matches("[1-9a-gU-Z]")); // 输出为 true
System.out.println("b".matches("[1-9a-gU-Z]")); // 输出为 true
System.out.println("X".matches("[1-9a-gU-Z]")); // 输出为 true
System.out.println("A".matches("[1-9a-gU-Z]")); // 输出为 false

考虑一个实际需求,有许许多多以下格式的字符串,你需要用正则表达式匹配出其姓名和年龄。
Name:Aurora Age:18
其中还夹杂着一些无关紧要的数据
Name:Bob Age:20
错误的数据有着各种各样错误的格式
Name:Cassin Age:22

观察字符串的规则,只需要用 Name:\w+\s*Age:\d{1,3} 就能匹配了。

System.out.println("Name:Aurora   Age:18".matches("Name:\\w+\\s*Age:\\d{1,3}")); // 输出为 true
System.out.println("其中还夹杂着一些无关紧要的数据".matches("Name:\\w+\\s*Age:\\d{1,3}")); // 输出为 false
System.out.println("Name:Bob      Age:20".matches("Name:\\w+\\s*Age:\\d{1,3}")); // 输出为 true
System.out.println("错误的数据有着各种各样错误的格式".matches("Name:\\w+\\s*Age:\\d{1,3}")); // 输出为 false
System.out.println("Name:Cassin   Age:22".matches("Name:\\w+\\s*Age:\\d{1,3}")); // 输出为 true

Pattern pattern = Pattern.compile("Name:(\\w+)\\s*Age:(\\d{1,3})");
Matcher matcher = pattern.matcher("Name:Aurora   Age:18");
if(matcher.matches()) {
    String group1 = matcher.group(1);
    String group2 = matcher.group(2);
    System.out.println(group1);   // 输出为 Aurora
    System.out.println(group2);   // 输出为 18
}

只要用 () 将需要取值的地方括起来,传给 Pattern 对象,再用 Pattern 对象匹配后获得的 Matcher 对象来取值就行了。每个匹配的值将会按照顺序保存在 Matcher 对象的 group 中。用 () 把 \\w+ 和 \\d{1,3} 分别括起来了,判断 Pattern 对象与字符串是否匹配的方法是 Matcher.matches(),如果匹配成功,这个函数将返回 true,如果匹配失败,则返回 false。

group(0) 被用来保存整个匹配的字符串了。

考虑一个实际场景:你有一个让用户输入标签的输入框,用户可以输入多个标签。可是你并没有提示用户,标签之前用什么间隔符号隔开。
二分,回溯,递归,分治
搜索;查找;旋转;遍历
数论 图论 逻辑 概率

System.out.println(Arrays.toString("二分,回溯,递归,分治".split("[,;\\s]+")));
System.out.println(Arrays.toString("搜索;查找;旋转;遍历".split("[,;\\s]+")));
System.out.println(Arrays.toString("数论 图论 逻辑 概率".split("[,;\\s]+")));

System.out.println("二分,回溯,递归,分治".replaceAll("[,;\\s]+", ";"));
System.out.println("搜索;查找;旋转;遍历".replaceAll("[,;\\s]+", ";"));
System.out.println("数论 图论 逻辑 概率".replaceAll("[,;\\s]+", ";"));

在 replaceAll 的第二个参数中,我们可以通过 $1,$2,…来反向引用匹配到的子串。只要将需要引用的部分用 () 括起来就可以了。

System.out.println("二分,回溯,递归,分治".replaceAll("([,;\\s]+)", "---$1---"));
System.out.println("搜索;查找;旋转;遍历".replaceAll("([,;\\s]+)", "---$1---"));
System.out.println("数论 图论 逻辑 概率".replaceAll("([,;\\s]+)", "---$1---"));

输出为:

二分---,---回溯---,---递归---,---分治
搜索---;---查找---;---旋转---;---遍历
数论--- ---图论--- ---逻辑--- ---概率

贪婪匹配和贪心算法原理是一致的。与之对应的匹配方式叫做 非贪婪匹配,非贪婪匹配 会在能匹配目标字符串的前提下,尽可能少的向后匹配。
在需要非贪婪匹配的正则表达式后面加个 ? 即可表示非贪婪匹配。

Pattern pattern = Pattern.compile("(\\w+?)(e*)");
Matcher matcher = pattern.matcher("LeetCode");
if (matcher.matches()) {
    String group1 = matcher.group(1);
    String group2 = matcher.group(2);
    System.out.println("group1 = " + group1 + ", length = " + group1.length());
    System.out.println("group2 = " + group2 + ", length = " + group2.length());
}

str.replaceAll(“[\\.。]+”, “”) 可以匹配所有的. 和 。

消除str.replaceAll(“[^0-9a-zA-Z]”, “”)消除所有的非大小写字母和数字的非法字符。

Java8 中Stream尝鲜

December 16th, 2019 by JasonLe's Tech 309 views

Stream简介

  • Java 8引入了全新的Stream API。这里的Stream和I/O流不同,它更像具有Iterable的集合类,但行为和集合类又有所不同。
  • stream是对集合对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作,或者大批量数据操作。
  • 只要给出需要对其包含的元素执行什么操作,比如 “过滤掉长度大于 10 的字符串”、“获取每个字符串的首字母”等,Stream 会隐式地在内部进行遍历,做出相应的数据转换。

为什么要使用Stream

  • 函数式编程带来的好处尤为明显。这种代码更多地表达了业务逻辑的意图,而不是它的实现机制。易读的代码也易于维护、更可靠、更不容易出错。
  • 高端

Filter

  • 遍历数据并检查其中的元素时使用。
  • filter接受一个函数作为参数,该函数用Lambda表达式表示。

/**
     * 过滤所有的男性
     */
    public static void fiterSex(){
        List<PersonModel> data = Data.getData();

        //old
        List<PersonModel> temp=new ArrayList<>();
        for (PersonModel person:data) {
            if ("男".equals(person.getSex())){
                temp.add(person);
            }
        }
        System.out.println(temp);
        //new
        List<PersonModel> collect = data
                .stream()
                .filter(person -> "男".equals(person.getSex()))
                .collect(toList());
        System.out.println(collect);
    }

    /**
     * 过滤所有的男性 并且小于20岁
     */
    public static void fiterSexAndAge(){
        List<PersonModel> data = Data.getData();

        //old
        List<PersonModel> temp=new ArrayList<>();
        for (PersonModel person:data) {
            if ("男".equals(person.getSex())&&person.getAge()<20){
                temp.add(person);
            }
        }

        //new 1
        List<PersonModel> collect = data
                .stream()
                .filter(person -> {
                    if ("男".equals(person.getSex())&&person.getAge()<20){
                        return true;
                    }
                    return false;
                })
                .collect(toList());
        //new 2
        List<PersonModel> collect1 = data
                .stream()
                .filter(person -> ("男".equals(person.getSex())&&person.getAge()<20))
                .collect(toList());

    }

Map

  • map生成的是个一对一映射,for的作用
  • 比较常用
  • 而且很简单

 /**
     * 取出所有的用户名字
     */
    public static void getUserNameList(){
        List<PersonModel> data = Data.getData();

        //old
        List<String> list=new ArrayList<>();
        for (PersonModel persion:data) {
            list.add(persion.getName());
        }
        System.out.println(list);

        //new 1
        List<String> collect = data.stream().map(person -> person.getName()).collect(toList());
        System.out.println(collect);

        //new 2
        List<String> collect1 = data.stream().map(PersonModel::getName).collect(toList());
        System.out.println(collect1);

        //new 3
        List<String> collect2 = data.stream().map(person -> {
            System.out.println(person.getName());
            return person.getName();
        }).collect(toList());
    }

FlatMap

  • 顾名思义,跟map差不多,更深层次的操作
  • 但还是有区别的
  • map和flat返回值不同
  • Map 每个输入元素,都按照规则转换成为另外一个元素。
    还有一些场景,是一对多映射关系的,这时需要 flatMap。
  • Map一对一
  • Flatmap一对多
  • map和flatMap的方法声明是不一样的
    • <r> Stream<r> map(Function mapper);
    • <r> Stream<r> flatMap(Function> mapper);
  • map和flatMap的区别:我个人认为,flatMap的可以处理更深层次的数据,入参为多个list,结果可以返回为一个list,而map是一对一的,入参是多个list,结果返回必须是多个list。通俗的说,如果入参都是对象,那么flatMap可以操作对象里面的对象,而map只能操作第一层。
public static void flatMapString() {
        List<PersonModel> data = Data.getData();
        //返回类型不一样
        List<String> collect = data.stream()
                .flatMap(person -> Arrays.stream(person.getName().split(" "))).collect(toList());

        List<Stream<String>> collect1 = data.stream()
                .map(person -> Arrays.stream(person.getName().split(" "))).collect(toList());

        //用map实现
        List<String> collect2 = data.stream()
                .map(person -> person.getName().split(" "))
                .flatMap(Arrays::stream).collect(toList());
        //另一种方式
        List<String> collect3 = data.stream()
                .map(person -> person.getName().split(" "))
                .flatMap(str -> Arrays.asList(str).stream()).collect(toList());
}

Collect

  • collect在流中生成列表,map,等常用的数据结构
  • toList()
  • toSet()
  • toMap()
  • 自定义
/**
     * toList
     */
    public static void toListTest(){
        List<PersonModel> data = Data.getData();
        List<String> collect = data.stream()
                .map(PersonModel::getName)
                .collect(Collectors.toList());
    }

    /**
     * toSet
     */
    public static void toSetTest(){
        List<PersonModel> data = Data.getData();
        Set<String> collect = data.stream()
                .map(PersonModel::getName)
                .collect(Collectors.toSet());
    }

    /**
     * toMap
     */
    public static void toMapTest(){
        List<PersonModel> data = Data.getData();
        Map<String, Integer> collect = data.stream()
                .collect(
                        Collectors.toMap(PersonModel::getName, PersonModel::getAge)
                );

        data.stream()
                .collect(Collectors.toMap(per->per.getName(), value->{
            return value+"1";
        }));
    }

    /**
     * 指定类型
     */
    public static void toTreeSetTest(){
        List<PersonModel> data = Data.getData();
        TreeSet<PersonModel> collect = data.stream()
                .collect(Collectors.toCollection(TreeSet::new));
        System.out.println(collect);
    }

    /**
     * 分组
     */
    public static void toGroupTest(){
        List<PersonModel> data = Data.getData();
        Map<Boolean, List<PersonModel>> collect = data.stream()
                .collect(Collectors.groupingBy(per -> "男".equals(per.getSex())));
        System.out.println(collect);
    }

    /**
     * 分隔
     */
    public static void toJoiningTest(){
        List<PersonModel> data = Data.getData();
        String collect = data.stream()
                .map(personModel -> personModel.getName())
                .collect(Collectors.joining(",", "{", "}"));
        System.out.println(collect);
    }

    /**
     * 自定义
     */
    public static void reduce(){
        List<String> collect = Stream.of("1", "2", "3").collect(
                Collectors.reducing(new ArrayList<String>(), x -> Arrays.asList(x), (y, z) -> {
                    y.addAll(z);
                    return y;
                }));
        System.out.println(collect);
    }

调试

  • list.map.fiter.map.xx 为链式调用,最终调用collect(xx)返回结果
  • 分惰性求值和及早求值
  • 判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。如果返回值是 Stream,那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果。
  • 通过peek可以查看每个值,同时能继续操作流
private static void peekTest() {
        List<PersonModel> data = Data.getData();

        //peek打印出遍历的每个per
        data.stream().map(per->per.getName()).peek(p->{
            System.out.println(p);
        }).collect(toList());
}

理解 Linux backlog/somaxconn 内核参数

November 21st, 2019 by JasonLe's Tech 292 views

引言

之前线上TcpExt.ListenOverflows,然后通过ss -tlnp 查看Send-Q偏小导致后端服务器 Socket accept 队列满,系统的 somaxconn 内核参数默认太小。

TCP SYN_REVD, ESTABELLISHED 状态对应的队列

TCP 建立连接时要经过 3 次握手,在客户端向服务器发起连接时,
对于服务器而言,一个完整的连接建立过程,服务器会经历 2 种 TCP 状态:SYN_REVD, ESTABELLISHED。

对应也会维护两个队列:
1. 一个存放 SYN 的队列(半连接队列)
2. 一个存放已经完成连接的队列(全连接队列)

当一个连接的状态是 SYN RECEIVED 时,它会被放在 SYN 队列中。
当它的状态变为 ESTABLISHED 时,它会被转移到另一个队列。
所以后端的应用程序只从已完成的连接的队列中获取请求。

如果一个服务器要处理大量网络连接,且并发性比较高,那么这两个队列长度就非常重要了。
因为,即使服务器的硬件配置非常高,服务器端程序性能很好,
但是这两个队列非常小,那么经常会出现客户端连接不上的现象,
因为这两个队列一旦满了后,很容易丢包,或者连接被复位。
所以,如果服务器并发访问量非常高,那么这两个队列的设置就非常重要了。

Linux backlog 参数意义

对于 Linux 而言,基本上任意语言实现的通信框架或服务器程序在构造 socket server 时,都提供了 backlog 这个参数,
因为在监听端口时,都会调用系统底层 API: int listen(int sockfd, int backlog);

listen 函数中 backlog 参数的定义如下:

Now it specifies the queue length for completely established sockets waiting to be accepted,
instead of the number of incomplete connection requests.
The maximum length of the queue for incomplete sockets can be set using the tcp_max_syn_backlog sysctl.
When syncookies are enabled there is no logical maximum length and this sysctl setting is ignored.
If the socket is of type AF_INET, and the backlog argument is greater than the constant SOMAXCONN(128 default),
it is silently truncated to SOMAXCONN.

backlog 参数描述的是服务器端 TCP ESTABELLISHED 状态对应的全连接队列长度。

全连接队列长度如何计算?
如果 backlog 大于内核参数 net.core.somaxconn,则以 net.core.somaxconn 为准,
即全连接队列长度 = min(backlog, 内核参数 net.core.somaxconn),net.core.somaxconn 默认为 128。
这个很好理解,net.core.somaxconn 定义了系统级别的全连接队列最大长度,
backlog 只是应用层传入的参数,不可能超过内核参数,所以 backlog 必须小于等于 net.core.somaxconn。

半连接队列长度如何计算?
半连接队列长度由内核参数 tcp_max_syn_backlog 决定,
当使用 SYN Cookie 时(就是内核参数 net.ipv4.tcp_syncookies = 1),这个参数无效,
半连接队列的最大长度为 backlog、内核参数 net.core.somaxconn、内核参数 tcp_max_syn_backlog 的最小值。
即半连接队列长度 = min(backlog, 内核参数 net.core.somaxconn,内核参数 tcp_max_syn_backlog)。
这个公式实际上规定半连接队列长度不能超过全连接队列长度。

其实,对于 Nginx/Tomcat 等这种 Web 服务器,都提供了 backlog 参数设置入口,
当然它们都会有默认值,通常这个默认值都不会太大(包括内核默认的半连接队列和全连接队列长度)。
如果应用并发访问非常高,只增大应用层 backlog 是没有意义的,因为可能内核参数关于连接队列设置的都很小,
一定要综合应用层 backlog 和内核参数一起看,通过公式很容易调整出正确的设置。

 

ACID/CAP/BASE/二段提交/三段提交/TCC

November 6th, 2019 by JasonLe's Tech 321 views

关系型数据库完全满足ACID的特性。

ACID指的是:A: Atomicity 原子性 C: Consistency 一致性 I: Isolation 隔离性 D: Durability 持久性

具有ACID的特性的数据库支持强一致性,强一致性代表数据库本身不会出现不一致,每个事务是原子的,或者成功或者失败,事物间是隔离的,互相完全不影响,而且最终状态是持久落盘的,因此,数据库会从一个明确的状态到另外一个明确的状态,中间的临时状态是不会出现的,如果出现也会及时的自动的修复,因此是强一致的。

如果你在为交易相关系统做技术选型,交易的存储应该只考虑关系型数据库,对于核心系统,如果需要较好的性能,可以考虑使用更强悍的硬件,这种向上扩展(升级硬件)虽然成本较高,但是是最简单粗暴有效的方式,另外,Nosql完全不适合交易场景,Nosql主要用来做数据分析、ETL、报表、数据挖掘、推荐、日志处理等非交易场景。

CAP(帽子理论)

C:Consistency,一致性, 数据一致更新,所有数据变动都是同步的

A:Availability,可用性, 好的响应性能,完全的可用性指的是在任何故障模型下,服务都会在有限的时间处理响应

P:Partition tolerance,分区容错性,可靠性

帽子理论证明,任何分布式系统只可同时满足二点,没法三者兼顾。关系型数据库由于关系型数据库是单节点的,因此,不具有分区容错性,但是具有一致性和可用性,而分布式的服务化系统都需要满足分区容错性,那么我们必须在一致性和可用性中进行权衡,具体表现在服务化系统处理的异常请求在某一个时间段内可能是不完全的,但是经过自动的或者手工的补偿后,达到了最终的一致性。

BASE模型与ACID模型截然不同,满足CAP理论,通过牺牲强一致性,获得可用性。

一般应用在服务化系统的应用层或者大数据处理系统,通过达到最终一致性来尽量满足业务的绝大部分需求。

BASE模型包含个三个元素:

1.BA:Basically Available,基本可用

2.S:Soft State,软状态,状态可以有一段时间不同步

3.E:Eventually Consistent,最终一致,最终数据是一致的就可以了,而不是时时保持强一致

分布式一致性协议

两阶段如下:

1.准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,参与者会写redo或者undo日志(这也是前面提起的Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交

2.提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源

两阶段提交协议

我们看到两阶段提交协议在准备阶段锁定资源,是一个重量级的操作,并能保证强一致性,但是实现起来复杂、成本较高,不够灵活,更重要的是它有如下致命的问题:

1.阻塞:从上面的描述来看,对于任何一次指令必须收到明确的响应,才会继续做下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放

2.单点故障:如果协调者宕机,参与者没有了协调者指挥,会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果之前协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接受,并且参与者接收后也宕机,新上任的协调者无法处理这种情况

3.脑裂:协调者发送提交指令,有的参与者接收到执行了事务,有的参与者没有接收到事务,就没有执行事务,多个参与者之间是不一致的

上面所有的这些问题,都是需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。

三阶段提交协议

三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为三个阶段:

1.询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是还是不是,而不需要做真正的操作,这个阶段超时导致中止

2.准备阶段:如果在询问阶段所有的参与者都返回可以执行操作,协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作,但是不提交操作;如果在询问阶段任何参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的的准备阶段是相似的,这个阶段超时导致成功

3.提交阶段:如果每个参与者在准备阶段返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致

三阶段提交协议

然而,这里与两阶段提交协议有两个主要的不同:

1.增加了一个询问阶段,询问阶段可以确保尽可能早的发现无法执行操作而需要中止的行为,但是它并不能发现所有的这种行为,只会减少这种情况的发生

2.在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,协调者和参与者都继续提交事务,默认为成功,这也是根据概率统计上超时后默认成功的正确性最大

三阶段提交协议与两阶段提交协议相比,具有如上的优点,但是一旦发生超时,系统仍然会发生不一致,只不过这种情况很少见罢了,好处就是至少不会阻塞和永远锁定资源。

TCC

无论两阶段还是三阶段方案中都包含多个参与者、多个阶段实现一个事务,实现复杂,性能也是一个很大的问题,因此,在互联网高并发系统中,鲜有使用两阶段提交和三阶段提交协议的场景。

阿里巴巴提出了新的TCC协议,TCC协议将一个任务拆分成Try、Confirm、Cancel,正常的流程会先执行Try,如果执行没有问题,再执行Confirm,如果执行过程中出了问题,则执行操作的逆操Cancel,从正常的流程上讲,这仍然是一个两阶段的提交协议,但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个参与者出现了问题,协调者通过执行操作的逆操作来取消之前的操作,达到最终的一致状态。

可以看出,从时序上,如果遇到极端情况下TCC会有很多问题的,例如,如果在Cancel的时候一些参与者收到指令,而一些参与者没有收到指令,整个系统仍然是不一致的,这种复杂的情况,系统首先会通过补偿的方式,尝试自动修复的,如果系统无法修复,必须由人工参与解决。

从TCC的逻辑上看,可以说TCC是简化版的三阶段提交协议,解决了两阶段提交协议的阻塞问题,但是没有解决极端情况下会出现不一致和脑裂的问题。然而,TCC通过自动化补偿手段,会把需要人工处理的不一致情况降到到最少,也是一种非常有用的解决方案,根据线人,阿里在内部的一些中间件上实现了TCC模式。

我们给出一个使用TCC的实际案例,在秒杀的场景,用户发起下单请求,应用层先查询库存,确认商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付,由于某种原因用户支付失败,或者支付超时,系统会自动将锁定的库存解锁供其他用户秒杀。

总结一下,两阶段提交协议、三阶段提交协议、TCC协议都能保证分布式事务的一致性,他们保证的分布式系统的一致性从强到弱,TCC达到的目标是最终一致性,其中任何一种方法都可以不同程度的解决案例2:转账、案例3:下订单和扣库存的问题,只是实现的一致性的级别不一样而已,对于案例4:同步超时可以通过TCC的理念解决,如果同步调用超时,调用方可以使用fastfail策略,返回调用方的使用方失败的结果,同时调用服务的逆向cancel操作,保证服务的最终一致性。

分布式一致性的技术包括:paxos算法、raft算法、zab算法、nwr算法、一致性哈希等。

参考:

案例2:转账
转账是经典的不一致案例,设想一下银行为你处理一笔转账,扣减你账户上的余额,然后增加别人账户的余额;如果扣减你的账户余额成功,增加别人账户余额失败,那么你就会损失这笔资金。反过来,如果扣减你的账户余额失败,增加别人账户余额成功,那么银行就会损失这笔资金,银行需要赔付。对于资金处理系统来说,上面任何一种场景都是不允许发生的,一旦发生就会有资金损失,后果是不堪设想的,严重情况会让一个公司瞬间倒闭,可参考案例。

案例3:下订单和扣库存
电商系统中也有一个经典的案例,下订单和扣库存如何保持一致,如果先下订单,扣库存失败,那么将会导致超卖;如果下订单没有成功,扣库存成功,那么会导致少卖。两种情况都会导致运营成本的增加,严重情况下需要赔付。

案例4:同步超时
服务化的系统间调用常常因为网络问题导致系统间调用超时,即使是网络很好的机房,在亿次流量的基数下,同步调用超时也是家常便饭。系统A同步调用系统B超时,系统A可以明确得到超时反馈,但是无法确定系统B是否已经完成了预定的功能或者没有完成预定的功能。于是,系统A就迷茫了,不知道应该继续做什么,如何反馈给使用方。(曾经的一个B2B产品的客户要求接口超时重新通知他们,这个在技术上是难以实现的,因为服务器本身可能并不知道自己超时,可能会继续正常的返回数据,只是客户端并没有接受到结果罢了,因此这不是一个合理的解决方案)。

https://blog.csdn.net/qq_31854907/article/details/92796788