分类目录归档:技术

什么是感知哈希算法

感知哈希算法是一类哈希算法的总称,其作用在于生成每张图像的“指纹”(fingerprint)字符串,比较不同图像的指纹信息来判断图像的相似性。结果越接近图像越相似。感知哈希算法包括均值哈希(aHash)、感知哈希(pHash)和dHash(差异值哈希)。

aHash速度较快,但精确度较低;pHash则反其道而行之,精确度较高但速度较慢;dHash兼顾二者,精确度较高且速度较快。在得到64位hash值后,使用汉明距离量化两张图像的相似性。

汉明距离越大,图像的相似度越小,汉明距离越小,图像的相似度越大。

aHash

  1. 缩放图片:为了保留图像的结构,降低图像的信息量,需要去掉细节、大小和横纵比的差异,建议把图片统一缩放到8*8,共64个像素的图片;
  2. 转化为灰度图:把缩放后的图片转化为256阶的灰度图;
  3. 计算平均值: 计算进行灰度处理后图片的所有像素点的平均值;
  4. 比较像素灰度值:遍历灰度图片每一个像素,如果大于平均值记录为1,否则为0;
  5. 构造hash值:组合64个bit位生成hash值,顺序随意但前后保持一致性即可;
  6. 对比指纹:计算两幅图片的指纹,计算汉明距离。

pHash

感知哈希算法可以获得更精确的结果,它采用的是DCT(离散余弦变换)来降低频率。

  1. 缩小尺寸。为了简化了DCT的计算,pHash以小图片开始(建议图片大于8x8,32x32)。
  2.  简化色彩。与aHash相同,需要将图片转化成灰度图像,进一步简化计算量(具体算法见aHash算法步骤)。
  3. 计算DCT。DCT是把图片分解频率聚集和梯状形。这里以32x32的图片为例。
  4. 缩小DCT。DCT的结果为32x32大小的矩阵,但只需保留左上角的8x8的矩阵,这部分呈现了图片中的最低频率。
  5. 计算平均值。如同均值哈希一样,计算DCT的均值
  6. 进一步减小DCT。根据8x8的DCT矩阵进行比较,大于等于DCT均值的设为”1”,小于DCT均值的设为“0”。图片的整体结构保持不变的情况下,hash结果值不变。
  7. 构造hash值。组合64个bit位生成hash值,顺序随意但前后保持一致性即可。
  8. 对比指纹:计算两幅图片的指纹,计算汉明距离。

dHash

相比pHash,dHash的速度更快,相比aHash,dHash在效率几乎相同的情况下的效果要更好,它是基于渐变实现的。

  1. 缩小图片:收缩至9*8的大小,它有72的像素点;
  2. 转化为灰度图:把缩放后的图片转化为256阶的灰度图。(具体算法见aHash算法步骤);
  3. 计算差异值:计算相邻像素间的差异值,这样每行9个像素之间产生了8个不同的差异,一共8行,则产生了64个差异值;
  4. 比较差异值:如果前一个像素的颜色强度大于第二个像素,那么差异值就设置为“1”,如果不大于第二个像素,就设置“0”;
  5. 构造hash值:组合64个bit位生成hash值,顺序随意但前后保持一致性即可;
  6. 对比指纹:计算两幅图片的指纹,计算汉明距离。

概念解释

🚩汉明距离

是使用在数据传输差错控制编码里面的,汉明距离是一个概念,它表示两个(相同长度)字对应位不同的数量,我们以d(x,y)表示两个字x,y之间的汉明距离。对两个字符串进行异或运算,并统计结果为1的个数,那么这个数就是汉明距离。

例如:
1011101与1001001之间的汉明距离是2。
2143896与2233796之间的汉明距离是3。
"toned"与"roses"之间的汉明距离是3。

🚩灰度图相关算法(R = red, G = green, B = blue)

对于彩色转灰度,其基础的心理学公式为: Gray = R0.299 + G0.587 + B0.114,部分变种也很流行:

  1. 浮点算法:Gray=R0.3+G0.59+B0.11
  2. 整数方法:Gray=(R30+G59+B11)/100
  3. 移位方法:Gray =(R76+G151+B28)>>8;
  4. 平均值法:Gray=(R+G+B)/3;
  5. 仅取绿色:Gray=G;

🚩DCT变换

的全称是离散余弦变换(Discrete Cosine Transform),主要用于将数据或图像的压缩,能够将空域的信号转换到频域上,具有良好的去相关性的性能。DCT变换本身是无损的,但是在图像编码等领域给接下来的量化、哈弗曼编码等创造了很好的条件,同时,由于DCT变换时对称的,所以,我们可以在量化编码后利用DCT反变换,在接收端恢复原始的图像信息。对原始图像进行离散余弦变换,变换后DCT系数能量主要集中在左上角,其余大部分系数接近于零,DCT具有适用于图像压缩的特性。将变换后的DCT系数进行门限操作,将小于一定值得系数归零,这就是图像压缩中的量化过程,然后进行逆DCT运算,可以得到压缩后的图像。
离散余弦变换的原理:
一维DCT变换:

其中,f(i)为原始的信号,F(u)是DCT变换后的系数,N为原始信号的点数,c(u)可以认为是一个补偿系数,可以使DCT变换矩阵为正交矩阵。
二维离散余弦变换的正变换公式为:

试试ChatGPT自动生成的文章

这是一篇chatgpt 自动生成的文章,其主题为:如何照顾新生儿。原文如下:

照顾新生儿是每个父母最重要的责任之一。尽管新生儿看起来很脆弱,但只要掌握了正确的方法,就可以让他们健康成长。
首先,新生儿需要足够的睡眠。在前几个月,新生儿需要睡16-17个小时才能得到充分的休息。为了帮助他们入睡,可以给他们提供一个安静、温暖、舒适的环境。如果他们醒来哭闹,可以尝试安抚他们,例如轻轻抚摸、摇晃或唱歌。
其次,新生儿需要正确的喂养。对于母乳喂养的宝宝,母亲应该保持良好的营养和饮食习惯。对于人工喂养的宝宝,应该选择适合他们年龄和体重的配方奶粉,并按照说明书上的指示来配制和喂养。
此外,新生儿需要经常换尿布。为了避免尿布疹,应该经常更换干净的尿布,并在每次更换时清洁他们的臀部。
最后,新生儿需要接受定期的体检和疫苗接种。这可以确保他们的健康和安全,并防止一些常见的婴儿疾病。
总之,照顾新生儿需要耐心和细心。只要注意以上要点,就可以让你的宝宝健康成长。记住,爱是最好的营养,给你的宝宝充满爱和关怀吧!

简单,易懂,下次试试文章润色

为什么ES不使用b+树做索引而是倒排索引

B+树不适合作为全文搜索索引主要有以下两个原因:

  • 全文索引的文本字段通常会比较长,索引值本身会占用较大空间,从而会加大 B+树的深度,影响查询效率。
  • 全文索引往往需要全文搜索,不遵循最左匹配原则,使用 B+ 树可能导致索引失效。

倒排索引又称之为反向索引(inverted index)。和正排索引相反,倒排索引使用的是词来作为索引关键字,并同时记录了哪些文档中有这个词。

queryString中检索词加不加引号?

原文地址:Elasticsearch干货(九):queryString中检索词加不加引号?_桃花惜春风的博客-CSDN博客

这篇文章回答我最近在实际运用中遇到的疑问,总结下:

  • 不加引号,match匹配
  • 加引号,等同于match_phrase。配合"slop": 0,等同于term精准匹配

对于刚接触搜索或者Elasticsearch的小白来说对queryString可能接触的不多,但是对于早期从事搜索的人来说queryString并不会陌生,它可以理解成检索表达式,但并不是elasticsearch的queryDSL,他遵从的是Lucene语法。elasticsearch同样有接口应用于queryString。下面上个例子:

{
  "from": 0,
  "size": 100,
  "query": {
    "query_string": {
      "query": "TITLE:无人机"
    }
  }
}

"TITLE:无人机"就是一个queryString,是一个字符串,表示在TITLE字段中查询匹配“无人机”。
它还支持一些参数:

{
  "from": 0,
  "size": 100,
  "query": {
    "query_string": {
      "query": "TITLE:无人机"
      "fields": [],
      "type": "best_fields",
      "default_operator": "or",
      "max_determinized_states": 10000,
      "enable_position_increments": true,
      "fuzzy_transpositions": true,
      "boost": 1
    }
  }
}

具体的关于queryString本篇不再过多介绍,这里不是重点。

参考官网:https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html

从上述例子中我们知道了我们的需求是从TITLE中匹配“无人机”这个词。这个检索式没任何问题,因为一般的分词器都会把无人机这个词分出来,但随着我们检索的深入,我们会遇到一个问题,比如我们要搜索“铝合金轮毂”这个词,一般的分词器都不会分出这个词来,也就是我们所说的专有名词词库。那么会出现什么问题呢?我们试试就知道了

{
  "query": {
    "query_string": {
      "query": "TITLE:铝合金轮毂"
    }
  }
}

我们会发现匹配结果包含很多铝合金和轮毂的文档,可想而知是因为在检索的时候,是先把“铝合金轮毂‘这个词先分词,再去检索的,类似match检索。那么这样的话就会带来两个问题:

  1. 如果我不想用match,我想精准匹配只返回带“铝合金轮毂”而不是“铝合金”+“轮毂”怎么办?
  2. 如果词库里没有“铝合金轮毂”这个词,该怎么做精准匹配?

说到这,可能有些人就会说了,你把“铝合金轮毂”这个词加上引号不就行了。我们来具体看看行还是不行:

{
  "query": {
    "query_string": {
      "query": "TITLE:\"铝合金轮毂\""
    }
  }
}

我们还引号的目的是想做精准匹配,的确,早期的Lucene的确是这么做的。从返回结果上看,也的确达到了我们想要的记过——只返回“铝合金轮毂”。但是我想告诉大家的是,这其实是早期Lucene的一个bug,在Lucene4中已经被修复了——将检索词加引号并不是实现精准匹配。

下面我们开始验证。

{
  "query": {
    "term": {
      "TITLE": "铝合金轮毂"
    }
  }
}

我们使用term检索来验证,前面已经说到词库里是没有“铝合金轮毂”这个词的,也就是说分词器不会将“铝合金轮毂”作为倒排的一个term进行索引。这样来说,这个检索必然是没有数据返回,我们找一句话验证一下。

GET _analyze?pretty
{
  "analyzer": "ik_max_word",
  "text": "某汽车铝合金轮毂在使用一年后发现裂纹"
}

我们使用ik_max分词,避免有些猩猩不服,下面是分词结果:

{
  "tokens": [
    {
      "token": "某",
      "start_offset": 0,
      "end_offset": 1,
      "type": "CN_CHAR",
      "position": 0
    },
    {
      "token": "汽车",
      "start_offset": 1,
      "end_offset": 3,
      "type": "CN_WORD",
      "position": 1
    },
    {
      "token": "铝合金",
      "start_offset": 3,
      "end_offset": 6,
      "type": "CN_WORD",
      "position": 2
    },
    {
      "token": "合金",
      "start_offset": 4,
      "end_offset": 6,
      "type": "CN_WORD",
      "position": 3
    },
    {
      "token": "轮毂",
      "start_offset": 6,
      "end_offset": 8,
      "type": "CN_WORD",
      "position": 4
    },
    {
      "token": "在",
      "start_offset": 8,
      "end_offset": 9,
      "type": "CN_CHAR",
      "position": 5
    },
    {
      "token": "使用",
      "start_offset": 9,
      "end_offset": 11,
      "type": "CN_WORD",
      "position": 6
    },
    {
      "token": "一年",
      "start_offset": 11,
      "end_offset": 13,
      "type": "CN_WORD",
      "position": 7
    },
    {
      "token": "一",
      "start_offset": 11,
      "end_offset": 12,
      "type": "TYPE_CNUM",
      "position": 8
    },
    {
      "token": "年后",
      "start_offset": 12,
      "end_offset": 14,
      "type": "CN_WORD",
      "position": 9
    },
    {
      "token": "年",
      "start_offset": 12,
      "end_offset": 13,
      "type": "COUNT",
      "position": 10
    },
    {
      "token": "后",
      "start_offset": 13,
      "end_offset": 14,
      "type": "CN_CHAR",
      "position": 11
    },
    {
      "token": "发现",
      "start_offset": 14,
      "end_offset": 16,
      "type": "CN_WORD",
      "position": 12
    },
    {
      "token": "裂纹",
      "start_offset": 16,
      "end_offset": 18,
      "type": "CN_WORD",
      "position": 13
    }
  ]
}

从结果中可以发现,并没有“铝合金轮毂”这个词,所以这个时候使用term检索是不会检索到这条数据的。那为什么用querySring就可以检索的到呢?
因为我们主观带入的认为加上引号就是做了精准匹配,实际上早期的Lucene的确是这样,但这是Lucene的一个bug,早已经被修复了,修复的结果是queryString中带引号的检索词并不是使用精准匹配,而是使用短语匹配。

我们在使用Elasticsearch的过程中可能使用term和match的时候是最多的,用到短语匹配的可能并不是很多。但在特定的场景中,短语匹配的效果要远好于term和match。

{
    "query": {
        "match_phrase" : {
            "TITLE" : "铝合金轮毂",
            "slop": 0
        }
    }
}

match_phrase为短语匹配,短语匹配同样是先将检索词分词,支持自定义分词“analyzer”:“my_analyzer”,一般建议使用和索引分词同一个分词器的检索效果更好。与match不同的是在于参数slop,官方给的解释是

A phrase query matches terms up to a configurable slop (which defaults to 0) in any order. Transposed terms have a slop of 2.

翻译大概意思就是允许分词匹配顺序错误的次数, “slop”: 0表示不允许顺序错误,这样即使将“铝合金”和“轮毂”分开,但是结果中必须是“铝合金”和“轮毂”挨着的才会被检索到,同样可以实现term的检索效果。

以上就是使用queryString检索词加不加引号,以及如何检索精确检索词库中不存在的词的介绍。

Elasticsearch为记录添加时间戳timestamp

时间戳是表明某条数据产生的时间,代表了此数据在一个特定时间点已经存在的证据。
添加时间戳可以在索引数据时指定

$ curl -XPUT localhost:9200/my_index/my_type/1?timestamp=2022-11-23T23:43:38.388Z -d '{
    "title" : "世界杯",
    "message" : "亚洲队真牛逼"
}'

如果没有手动指定时间戳, source中是不会存在时间戳的。
如果想为每个索引文档自动创建时间戳,必须在创建索引时指定Mapping,将@timestamp设置为enable。否则,即使以后更改,新的数据也是无法加上时间戳的。

"properties": {
         "@timestamp":{
                   "format":"strict_date_optional_time||epoch_millis",
                   "type":"date"
                   "enabled":true
         }
}

若使用logstash来做日志收集,logstash会根据事件传输的当前时间自动给事件加上@timestamp字段。
时间戳的数据类型是date,Date类型在Elasticsearch中有三种方式:

  • 传入格式化的字符串,默认是ISO 8601标准
  • 使用毫秒的时间戳,长整型,直接将毫秒值传入即可
  • 使用秒的时间戳,整型

在Elasticsearch内部,对时间类型字段,是统一采用 UTC 时间。在做查询和显示是需要转换时间内容增加8个小时,调整时区为东八区。

elasticsearch match查询

es中的查询请求有两种方式,一种是简易版的查询,另外一种是使用JSON完整的请求体,叫做结构化查询(DSL)。
由于DSL查询更为直观也更为简易,所以大都使用这种方式。
DSL查询是POST过去一个json,由于post的请求是json格式的,所以存在很多灵活性,也有很多形式。
这里有一个地方注意的是官方文档里面给的例子的json结构只是一部分,并不是可以直接黏贴复制进去使用的。一般要在外面加个query为key的机构。

match

最简单的一个match例子:

查询和"哺乳期长湿疹怎么办"这个查询语句匹配的文档。

{
  "query": {
    "match": {
        "content" : {
            "query" : "哺乳期长湿疹怎么办"
        }
    }
  }
}

上面的查询匹配就会进行分词,比如"哺乳期长湿疹怎么办"会被分词为"哺乳期、哺乳、乳期、期长、湿疹、怎么办、怎么、办", 所有包含这些分词中的一个或多个的文档就会被搜索出来。
并且根据lucene的评分机制(TF/IDF)来进行评分。

match_phrase

比如上面一个例子,一个文档"哺乳期长湿疹怎么办"也会被搜索出来,那么想要精确匹配所有同时包含这些分词的文档怎么做?就要使用 match_phrase 了

{
  "query": {
    "match_phrase": {
        "content" : {
            "query" : "哺乳期长湿疹怎么办"
        }
    }
  }
}

完全匹配可能比较严,我们会希望有个可调节因子,少匹配一个也满足,那就需要使用到slop。

{
  "query": {
    "match_phrase": {
        "content" : {
            "query" : "哺乳期长湿疹怎么办",
            "slop" : 1
        }
    }
  }
}

multi_match

如果我们希望两个字段进行匹配,其中一个字段有这个文档就满足的话,使用multi_match

{
  "query": {
    "multi_match": {
        "query" : "哺乳期长湿疹怎么办",
        "fields" : ["title", "introduction"]
    }
  }
}

但是multi_match就涉及到匹配评分的问题了。

我们希望完全匹配的文档占的评分比较高,则需要使用best_fields

{
  "query": {
    "multi_match": {
      "query": "哺乳期长湿疹怎么办",
      "type": "best_fields",
      "fields": [
        "tag",
        "introduction"
      ],
      "tie_breaker": 0.3
    }
  }
}

意思就是完全匹配"宝马 发动机"的文档评分会比较靠前,如果只匹配宝马的文档评分乘以0.3的系数

我们希望越多字段匹配的文档评分越高,就要使用most_fields

{
  "query": {
    "multi_match": {
      "query": "哺乳期长湿疹怎么办",
      "type": "most_fields",
      "fields": [
        "tag",
        "introduction"
      ]
    }
  }
}

我们会希望这个词条的分词词汇是分配到不同字段中的,那么就使用cross_fields

{
  "query": {
    "multi_match": {
      "query": "哺乳期长湿疹怎么办",
      "type": "cross_fields",
      "fields": [
        "tag",
        "introduction"
      ]
    }
  }
}

如何计算两个字符串之间的文本相似度

前言

平时的编码中,我们经常需要判断两个文本的相似性,不管是用来做文本纠错或者去重等等,那么我们应该以什么维度来判断相似性呢?这些算法又怎么实现呢?这篇文章对常见的计算方式做一个记录。

Jaccard 相似度

首先是 Jaccard 相似度系数,下面是它在维基百科上的一个定义及计算公式。

The Jaccard index, also known as Intersection over Union and the Jaccard similarity coefficient (originally given the French name coefficient de communauté by Paul Jaccard), is a statistic used for gauging the similarity and diversity of sample sets. The Jaccard coefficient measures similarity between finite sample sets, and is defined as the size of the intersection divided by the size of the union of the sample sets:

其实总结就是一句话:集合的交集与集合的并集的比例.

java 代码实现如下:

    public static float jaccard(String a, String b) {
        if (a == null && b == null) {
            return 1f;
        }
        // 都为空相似度为 1
        if (a == null || b == null) {
            return 0f;
        }
        Set<Integer> aChar = a.chars().boxed().collect(Collectors.toSet());
        Set<Integer> bChar = b.chars().boxed().collect(Collectors.toSet());
        // 交集数量
        int intersection = SetUtils.intersection(aChar, bChar).size();
        if (intersection == 0) return 0;
        // 并集数量
        int union = SetUtils.union(aChar, bChar).size();
        return ((float) intersection) / (float)union;
    }

Sorensen Dice 相似度系数

与 Jaccard 类似,Dice 系数也是一种计算简单集合之间相似度的一种计算方式。与 Jaccard 不同的是,计算方式略有不同。下面是它的定义。

The Sørensen–Dice coefficient (see below for other names) is a statistic used to gauge the similarity of two samples. It was independently developed by the botanists Thorvald Sørensen[1] and Lee Raymond Dice,[2] who published in 1948 and 1945 respectively.

需要注意的是,他是:集合交集的 2 倍除以两个集合相加。并不是并集.

java 代码实现如下:

    public static float SorensenDice(String a, String b) {
        if (a == null && b == null) {
            return 1f;
        }
        if (a == null || b == null) {
            return 0F;
        }
        Set<Integer> aChars = a.chars().boxed().collect(Collectors.toSet());
        Set<Integer> bChars = b.chars().boxed().collect(Collectors.toSet());
        // 求交集数量
        int intersect = SetUtils.intersection(aChars, bChars).size();
        if (intersect == 0) {
            return 0F;
        }
        // 全集,两个集合直接加起来
        int aSize = aChars.size();
        int bSize = bChars.size();
        return (2 * (float) intersect) / ((float) (aSize + bSize));
    }

Levenshtein

莱文斯坦距离,又称 Levenshtein 距离,是编辑距离的一种。指两个字串之间,由一个转成另一个所需的最少编辑操作次数。

简单的说,就是用编辑距离表示字符串相似度, 编辑距离越小,字符串越相似。

java 实现代码如下:

    public static float Levenshtein(String a, String b) {
        if (a == null && b == null) {
            return 1f;
        }
        if (a == null || b == null) {
            return 0F;
        }
        int editDistance = editDis(a, b);
        return 1 - ((float) editDistance / Math.max(a.length(), b.length()));
    }

    private static int editDis(String a, String b) {

        int aLen = a.length();
        int bLen = b.length();

        if (aLen == 0) return aLen;
        if (bLen == 0) return bLen;

        int[][] v = new int[aLen + 1][bLen + 1];
        for (int i = 0; i <= aLen; ++i) {
            for (int j = 0; j <= bLen; ++j) {
                if (i == 0) {
                    v[i][j] = j;
                } else if (j == 0) {
                    v[i][j] = i;
                } else if (a.charAt(i - 1) == b.charAt(j - 1)) {
                    v[i][j] = v[i - 1][j - 1];
                } else {
                    v[i][j] = 1 + Math.min(v[i - 1][j - 1], Math.min(v[i][j - 1], v[i - 1][j]));
                }
            }
        }
        return v[aLen][bLen];
    }

代码中的编辑距离求解使用了经典的动态规划求解法。

我们使用了** 1 - ( 编辑距离 / 两个字符串的最大长度) ** 来表示相似度,这样可以得到符合我们语义的相似度。

汉明距离

汉明距离是编辑距离中的一个特殊情况,仅用来计算两个等长字符串中不一致的字符个数。

因此汉明距离不用考虑添加及删除,只需要对比不同即可,所以实现比较简单。

我们可以用similarity=汉明距离/长度来表示两个字符串的相似度。

java 代码如下:

    public static float hamming(String a, String b) {
        if (a == null || b == null) {
            return 0f;
        }
        if (a.length() != b.length()) {
            return 0f;
        }

        int disCount = 0;
        for (int i = 0; i < a.length(); i++) {
            if (a.charAt(i) != b.charAt(i)) {
                disCount++;
            }
        }
        return (float) disCount / (float) a.length();
    }

下面是测试用例:

        Assert.assertEquals(0.0f, StringSimilarity.hamming("java 开发", "大过年的干啥"), 0f);
        Assert.assertEquals(0.6666667f, StringSimilarity.hamming("大过年的吃肉", "大过年的干啥"), 0f);

余弦相似性

首先是余弦相似性的定义:

余弦相似性通过测量两个向量的夹角的余弦值来度量它们之间的相似性。0 度角的余弦值是 1,而其他任何角度的余弦值都不大于 1;并且其最小值是-1。从而两个向量之间的角度的余弦值确定两个向量是否大致指向相同的方向。两个向量有相同的指向时,余弦相似度的值为 1;两个向量夹角为 90°时,余弦相似度的值为 0;两个向量指向完全相反的方向时,余弦相似度的值为-1。这结果是与向量的长度无关的,仅仅与向量的指向方向相关。余弦相似度通常用于正空间,因此给出的值为 0 到 1 之间。

计算公式如下:

余弦我们都比较熟悉,那么是怎么用它来计算两个字符串之间的相似度呢?

首先我们将字符串向量化,之后就可以在一个平面空间中,求出他们向量之间夹角的余弦值即可。

字符串向量化怎么做呢?我举一个简单的例子:

A: 呼延十二
B: 呼延二十三

他们的并集 [呼,延,二,十,三]

向量就是并集中的每个字符在各自中出现的频率。
A 的向量:[1,1,1,1,0]
B 的向量:[1,1,1,1,1]

然后调用上面的公式计算即可。

java 代码实现如下:

    public static float cos(String a, String b) {
        if (a == null || b == null) {
            return 0F;
        }
        Set<Integer> aChar = a.chars().boxed().collect(Collectors.toSet());
        Set<Integer> bChar = b.chars().boxed().collect(Collectors.toSet());

        // 统计字频
        Map<Integer, Integer> aMap = new HashMap<>();
        Map<Integer, Integer> bMap = new HashMap<>();
        for (Integer a1 : aChar) {
            aMap.put(a1, aMap.getOrDefault(a1, 0) + 1);
        }
        for (Integer b1 : bChar) {
            bMap.put(b1, bMap.getOrDefault(b1, 0) + 1);
        }

        // 向量化
        Set<Integer> union = SetUtils.union(aChar, bChar);
        int[] aVec = new int[union.size()];
        int[] bVec = new int[union.size()];
        List<Integer> collect = new ArrayList<>(union);
        for (int i = 0; i < collect.size(); i++) {
            aVec[i] = aMap.getOrDefault(collect.get(i), 0);
            bVec[i] = bMap.getOrDefault(collect.get(i), 0);
        }

        // 分别计算三个参数
        int p1 = 0;
        for (int i = 0; i < aVec.length; i++) {
            p1 += (aVec[i] * bVec[i]);
        }

        float p2 = 0f;
        for (int i : aVec) {
            p2 += (i * i);
        }
        p2 = (float) Math.sqrt(p2);

        float p3 = 0f;
        for (int i : bVec) {
            p3 += (i * i);
        }
        p3 = (float) Math.sqrt(p3);

        return ((float) p1) / (p2 * p3);
    }

对上面的代码运行了测试用例,可以看到基本符合我们的期望。

        Assert.assertEquals(0.70710677f, StringSimilarity.cos("apple", "app"), 0f);
        Assert.assertEquals(0.8944272f, StringSimilarity.cos("呼延十二", "呼延二十三"), 0f);
        Assert.assertEquals(0.0f, StringSimilarity.cos("数据工程", "日本旅游"), 0f);

总结

本文简单的介绍了几种不同的计算纯文本之间相似度的方式,他们在一定程度上都是奏效的,但是,各自也有各自的一些含义在里面,比如有的使用编辑距离来描述,有的用向量夹角来描述。所以在使用到本文中的方式时,还是要多多了解他的原理,结合自己的业务实际,选择其中的一种或者几种进行使用。

作者:呼延十
原文链接:https://juejin.im/post/5dca66e0e51d456a2e6556f8

如何设计一个高性能 Elasticsearch mapping

前言

在关系型数据库设计当中,表的设计尤其重要,然而关系型数据库更关注的表与表之间的关系,以及表的划分是否合理,而 Elasticsearch 中却更加关注字段类型的设计,一个好的字段类型设计可以更好的利用 Elasticsearch 的搜索分析特性。

mapping

如果说我们想要用好 Elasticsearch,那么就必须要先了解 mapping 什么是 mapping。一句话:mapping是定义如何存储和索引文档及其包含的字段的过程。

mapping 能做什么

前面我们提到,在 Elasticsearch 中,mapping 类似于传统关系型数据库的表结构定义,主要做以下几件事:

  • 定义字段名称和字段类型。
  • 定义倒排索引相关的配置,比如是否被索引,是否可以被分词等。

mapping 可以分为两种:Dynamic mapping 和 Explicit mapping

Dynamic mapping

Dynamic mapping 即:动态映射。动态映射顾名思义就是 mapping 会被动态创建,也就是说我们不需要定义 mapping 就可以往一个索引插入数据,插入索引数据之后,Elasticsearch 会根据插入的数据自动推测数据类型,进而动创建 mapping

比如下面就是往一个不存在的索引 index_001 插入一条数据:

PUT index_001/_doc/1
{
  "name":"lonely wolf",
  "age": 18,
  "create_date":"2021-05-19 20:45:11",
  "update_date":"2021-05-23"
}

插入数据之后,执行 GET index_001 来查询一下索引信息:

可以发现,这时候索引已经被自动创建了,而且 age 字段被 Elasticsearch 定义为 long 类型,update_date 被定义为 data 类型,其他两个字段则被推测为 text 类型。

Elasticsearch 中自动映射类型规则可以通过 dynamic 参数进行配置,dynamic 类型有 4 种:

dynamic=true

默认值。当设置为 true 时,一旦有新字段插入文档,则 mapping 会被同步更新。

我们在上面的文档中再插入一个新文档,新文档新增一个 address 字段:

PUT index_001/_doc/2
{
  "name":"lonely wolf2",
  "age": 20,
  "create_date":"2021-05-23 11:37:11",
  "update_date":"2021-05-23",
  "address":"广东深圳"
}

然后再查看一下 mapping,可以看到 mapping 已经新增了一个 address 字段,mapping 字段被更新意味着该字段会加入索引:

dynamic=runtime

这个类型和 true 类型非常相似,但是有一个非常大的区别就是,虽然加入新字段也会更新 mapping,但是新加入的字段不会被索引,也就是不会使得索引变大,不过虽然不被索引,但是新加入的字段依然可以被查询,只是查询的代价会更大。所以这种类型一般不建议用在经常查询的条件字段上,而更适合用在一些不确定数据结构的日志类索引中。

修改 dynamic 类型:

PUT index_001/_mapping
{
  "dynamic":  "runtime"
}

新增一个文档,并加入一个新字段:

PUT index_001/_doc/3
{
  "email":"123@qq.com"
}

最后询一下 mapping,可以看到字段属性是 runtime,而且类型是 keyword

下表就是自动创建 mapping 时,Elasticsearch 的映射关系:

dynamic=false

当设置为 false 时,新加入的字段不会被更新到 mapping,也就是说新字段不会被索引,故以这个字段为条件进行搜索时,无法被搜索到(这一点要注意和 runtime 类型进行区分),不过虽然无法被索引,但是该字段会出现在 _source 中。也就是说该字段不能作为查询条件,但是能被查询出来

接下来我们将 dynamic 修改为 false,并新增一个字段来验证,可以发现新增的字段会出现在 _source 中,但是无法作为条件被查询出来:

dynamic=strict

这种类型最为严格,表示不允许新增一个不在 mapping 中的字段,一旦新增的字段不在 mapping 定义中,则直接报错:

是否可以修改 mapping 中的数据类型

在 Elasticsearch 中,一旦一个字段被定义在了 mapping 中,是无法被修改的,因为一旦字段被修改了,就会无法被索引(新增字段除外),所以一般我们需要修改索引的话,都会重建索引,并采用 reindex 操作来迁移数据。

关闭 dynamic mapping

可以通过以下两个配置来关闭 dynamic mapping,以下两个属性默认值均为 true,如果需要关闭,则需要修改为 false

action.auto_create_index: true
index.mapper.dynamic: true

Explicit mapping

Explicit mapping 即:显式映射。也就是说这时候我们需要显示的定义字段类型。

Elasticsearch 中支持的字段类型很多,在这里就举一些比较常用的字段类型:

text 类型

这是最常用的一种类型,存储字符串,用于全文索引。当字段被定义为 text 类型时,默认不能用于聚合,排序等操作:

可以看到,用 text 类型字段排序汇报凑,如果想要允许这些操作,可以通过设置 fielddata=true,如下

PUT my-index-011/_mapping
{
  "properties": {
    "my_field": { 
      "type":     "text",
      "fielddata": true
    }
  }
}

field 字段存储在堆内存中,因为其涉及到的计算比较消耗性能,所以一般不建议设置 fielddata=true,而是通过建立一个 keyword 子域来实现(默认方式):

PUT index_111
{
  "mappings": {
    "properties": {
      "my_field": { 
        "type": "text",
        "fields": {
          "keyword": { 
            "type": "keyword"
          }
        }
      }
    }
  }
}

这种定义方式我们可以将一个字段同时作为 text 和 keyword 类型使用,如果要用于聚合或者排序等操作则可以使用 字段名.keyword 来作为字段名来进行操作:

keyword 类型

这种类型也非常常用,该字段存储的数据表示一个整体,不可被分词,所以一般不会用来定义大本文的全文检索字段,而是用来存储一些结构化的字符串,比如:id,邮箱,标签等。

keyword 类型一般用于聚合,排序等操作。除此之外,该字段还有两种衍生类型:constant_keyword 和 wildcard

  • constant_keyword:一般用于定义常量类型,比如一个索引中某一个字段全部为同一个值,可以定义为这种类型。
  • wildcard:一般用于模糊匹配查询或者正则匹配查询。

如下就是一个模糊匹配查询的示例(可以配合通配符使用,类似于关系型数据库的 like 操作):

GET index_112/_search
{
  "query": {
    "wildcard": {
      "my_wildcard": {
        "value": "*quite*lengthy"
      }
    }
  }
}

date 类型

用于定义日期类型,定义日期类型的同时,可以通过 format 来指定日期的格式:

PUT index_113
{
  "mappings": {
    "properties": {
      "date": {
        "type":   "date",
        "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
      }
    }
  }
}

numeric 类型

Elasticsearch 中提供了比较多的格式用来表示不同长度的数字类型:

定义方式如下所示:

PUT index_002
{
  "mappings": {
    "properties": {
      "number_of_bytes": {
        "type": "integer"
      },
      "time_in_seconds": {
        "type": "float"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      }
    }
  }
}

boolean 类型

布尔类型比较简单,只有 true 和 false 两种:

PUT index_001
{
  "mappings": {
    "properties": {
      "is_published": {
        "type": "boolean"
      }
    }
  }
}

其他类型

除了上面介绍的一些比较常用的数据类型,Elasticsearch 中还有一些高级数据类型:如 Nested(嵌套类型),地理数据类型,ip 类型等。

总结

Elasticsearch 中支持动态 mapping 和显示 mapping 两种,在使用中有时候可以先插入一条数据到临时索引,等自动生成 mapping 之后,在对现有 mapping 进行修改调整,在字段上尤其要考虑好 text 类型和 keyword 类型的设置,如果需要支持全文搜索和分词搜索,则需要使用 text 类型,需要支持关键字模糊搜索或者聚合排序等操作可以考虑使用 keyword 字段。

误入集群的节点引发的shard unassigned线上故障

背景是对线上的es7集群(这里简称为集群A)进行动态扩容,A集群配置3个node节点,在进行节点重启时误重启了其他集群(这里简称为集群B)废弃的节点,导致AB两个集群不可用,red状态。

现象

  • AB两个集群red状态
  • A集群有节点offline
  • B集群节点都online,但多个index的主副分片unassigned

解决办法

这里先交代下最后的解决办法:

A集群
对于A集群,找到正确的es安装地址,动态扩容后重启即可。

B集群
而对于B集群就比较复杂些,着重解决shard unassigned问题。

  1. 检查哪些index的shard出现unassigned问题
    可以通过命令:GET _cat/shards?h=index,shard,prirep,state,unassigned.reason定位到出现问题的index。如果你平常是使用kibana来查询监控集群的,当集群status=red时,kibana是无法连接到集群的,也就无法使用Dev Tools。这里推荐你使用Cerebro,一款提供对于ElasticSearch更加友好的可视化操作支持的工具。直接执行cat apis
  2. 三思而后行
    要从出现问题的index角度出发,如果是无关紧要或者是已经下线不在使用的,可以直接删除;反之删除index操作要慎重。
    根据unassigned.reason具体问题具体分析,根据官方的解释有如下:
  • INDEX_CREATED:由于创建索引的API导致未分配。
  • CLUSTER_RECOVERED :由于完全集群恢复导致未分配。
  • INDEX_REOPENED :由于打开open或关闭close一个索引导致未分配。
  • DANGLING_INDEX_IMPORTED :由于导入dangling索引的结果导致未分配。
  • NEW_INDEX_RESTORED :由于恢复到新索引导致未分配。
  • EXISTING_INDEX_RESTORED :由于恢复到已关闭的索引导致未分配。
  • REPLICA_ADDED:由于显式添加副本分片导致未分配。
  • ALLOCATION_FAILED :由于分片分配失败导致未分配。
  • NODE_LEFT :由于承载该分片的节点离开集群导致未分配。
  • REINITIALIZED :由于当分片从开始移动到初始化时导致未分配(例如,使用影子shadow副本分片)。
  • REROUTE_CANCELLED :作为显式取消重新路由命令的结果取消分配。
  • REALLOCATED_REPLICA :确定更好的副本位置被标定使用,导致现有的副本分配被取消,出现未分配。

根据目前查询的结果来看,我是属于DANGLING_INDEX_IMPORTED

我做了哪些尝试

❎由于出现故障,导致分配分片的5次(默认)重试机会用完了,所以不会再自动分配,需要进行retry

POST /_cluster/reroute?retry_failed=true

❎调整index的副本数为0

curl -XPUT 'localhost:9200/<INDEX_NAME>/_settings' -d '{"number_of_replicas": 0}'

❎调整index的刷新时间为5m

curl -XPUT 'localhost:9200/<INDEX_NAME>/_settings' -d ' { "settings": { "index.unassigned.node_left.delayed_timeout": "5m" } }'

✅手动分配分片,将主分片分配(只丢失部分数据,出现问题的index均为不在使用的)

POST _cluster/reroute
{
  "commands": [
    {
      "allocate_stale_primary": {
        "index": "index_name",
        "shard": 4,
        "node": "node1",
        "accept_data_loss" : true
      }
    }
  ]
}

分析

搜罗下网上归纳的出现unassigned的主要原因,建议直接阅读原文:How to Resolve Unassigned Shards in Elasticsearch | Datadog

补充下

不同版本的es的reroute语法

ES2.x的reroute命令:

curl -XPOST 'localhost:9200/_cluster/reroute' -d '{
    "commands" : [ {
        "move" :
            {
              "index" : "test", "shard" : 0,
              "from_node" : "node1", "to_node" : "node2"
            }
        },
        {
          "allocate" : {
              "index" : "test", "shard" : 1, "node" : "node3",
              "allow_primary":true  (表示接受主分片数据丢失)
          }
        }
    ]
}'

ES5.x升级之后,已经把主分片的恢复和副本的恢复进行了区分,Cluster Reroute | Elasticsearch Reference [5.6] | Elastic

curl -XPOST 'localhost:9200/_cluster/reroute' -d '{
    "commands" : [ {
        "move" :
            {
              "index" : "test", "shard" : 0,
              "from_node" : "node1", "to_node" : "node2"
            }
        },
        {
          "allocate_replica" : {
              "index" : "test", "shard" : 1, "node" : "node3"
          }
        },
        {
            "allocate_empty_primary": {
              "index" : "test", "shard" : 1, "node" : "node3",
              "accept_data_loss":true (接受数据丢失)
          }
        }
    ]
}'

不同情况下手动分配分片的流程

  • red情况下手动分配主分配操作流程
    • 先查询未分配的主分片原来是在哪个节点上 GET /order_orderlist/_shard_stores
    • 将主分片分配(只丢失部分数据)
POST _cluster/reroute
{
  "commands": [
    {
      "allocate_stale_primary": {
        "index": "index_name",
        "shard": 4,
        "node": "node1",
        "accept_data_loss" : true
      }
    }
  ]
}

如果数据不重要,可以不用放到原来的节点上,直接新建一个空分片替代

 POST _cluster/reroute
{
  "commands": [
    {
      "allocate_empty_primary": {
        "index": "test_11",
        "shard": 2,
        "node": "node0",
        "accept_data_loss" : true
      }
    }
  ]
}
  • yellow情况下手动分配副本分片操作:
POST /_cluster/reroute
{ 
  "commands" :[
    { 
      "allocate_replica" : 
         { 
           "index" : "index_name", 
           "shard" : 0, 
           "node": "node1"
         }
      }
   ]
 }

主分片的恢复用allocate_empty_primary,而副本的恢复用allocate_replica

补充:在unassigned的分片比较多的时候,可以使用脚本

#!/bin/bash
for index in $(curl  -s 'http://localhost:9200/_cat/shards' | grep UNASSIGNED | awk '{print $1}' | sort | uniq); do
      for shard in $(curl  -s 'http://localhost:9200/_cat/shards' | grep UNASSIGNED | grep $index | awk '{print $2}' | sort | uniq); do
          echo  $index $shard
          curl -XPOST 'localhost:9200/_cluster/reroute' -d "{
              'commands' : [ {
                    'allocate' : {
                        'index' : $index,
                        'shard' : $shard,
                        'node' : 'Master',
                        'allow_primary' : true
                    }
                  }
              ]
          }"

          sleep 5
      done
  done

可以参考的几篇文章:

  • 总结得最整的是 https://www.datadoghq.com/blog/elasticsearch-unassigned-shards/
  • 单独针对主shard出现unassigned的解决可以看http://blog.kiyanpro.com/2016/03/06/elasticsearch/reroute-unassigned-shards/
    https://t37.net/how-to-fix-your-elasticsearch-cluster-stuck-in-initializing-shards-mode.html
    http://www.wklken.me/posts/2015/05/23/elasticsearch-issues.html
  • 单独针对副本shard出现unassigned的解决可以看
    https://z0z0.me/recovering-unassigned-shards-on-elasticsearch/
    https://dpatil1410.wordpress.com/2016/09/24/its-red-how-do-i-recover-unassigned-elasticsearch-shards/

Pipenv vs Virtualenv vs Conda environment

目前,Python创建虚拟环境主要有三种方式,在这篇文章中,我想谈谈如何使用它们。

为什么需要虚拟环境?

在使用Python语言时,通过pip(pip3)来安装第三方包,但是由于pip的特性,系统中只能安装每个包的一个版本。但是在实际项目开发中,不同项目可能需要第三方包的不同版本,迫使我们需要根据实际需求不断进行更新或卸载相应的包,而如果我们直接使用本地的Python环境,会导致整体的开发环境相当混乱而不易管理,这时候我们就需要开辟一个独立干净的空间进行开发和部署,虚拟环境就孕育而生。

Virtualenv

Virtualenv 是目前最流行的 Python 虚拟环境配置工具,同时支持Python2和Python3,也可以为每个虚拟环境指定Python解释器。

请预先安装pip或者pip3(安装pip的三种方式),以pip3为例

一旦正常安装pip后,可使用以下命令安装Virtualenv:

pip3 install virtualenv

在终端或命令提示符下进入(cd)选择的目录搭建一个虚拟环境:

virtualenv venv

如果存在多个python解释器,可以选择指定一个Python解释器(比如python3.6),没有指定则由系统默认的解释器来搭建(变更默认的python版本可以看看 {% post_link Python/使用update-alternatives管理多个版本的Python %}):

virtualenv -p /usr/bin/python3.6 venv

将会在当前的目录中创建一个名venv的文件夹,这是一个独立的python运行环境,包含了Python可执行文件, 以及 pip库的一份拷贝,但第三方包需要重新安装。

要开始使用虚拟环境,其需要被激活:

source env/bin/activate

停用虚拟环境:

deactivate

virtualenv 的一个最大的缺点就是,每次开启虚拟环境之前要去虚拟环境所在目录下的 bin 目录下 source 一下 activate。这时候virtualenvwrapper出现了,它对不同的虚拟环境使用不同的目录进行管理,并且还省去了每次开启虚拟环境时候的 source 操作,使得虚拟环境更加好用。

pip3 install virtualenvwrapper

vim ~/.bashrc开始配置virtualenvwrapper:

export WORKON_HOME=$HOME/.virtualenvs
source /usr/local/bin/virtualenvwrapper.sh

接着执行 source ~/.bashrc(或./zshrc)。

注意:修改.bashrc还是.zshrc取决于你使用的那种 shell。

  • workon: 打印所有的虚拟环境;
  • mkvirtualenv xxx: 创建 xxx 虚拟环境,可以--python=/usr/bin/python3.6 指定python版本;
  • workon xxx: 使用 xxx 虚拟环境;
  • deactivate: 退出 xxx 虚拟环境;
  • rmvirtualenv xxx: 删除 xxx 虚拟环境。
  • lsvirtualenv : 列举所有的环境。
  • cdvirtualenv: 导航到当前激活的虚拟环境的目录中,比如说这样您就能够浏览它的 site-packages。
  • cdsitepackages: 和上面的类似,但是是直接进入到 site-packages 目录中。
  • lssitepackages : 显示 site-packages 目录中的内容。

PipEnv

pipenv 是 Pipfile 主要倡导者、requests 作者 Kenneth Reitz 写的一个命令行工具,主要包含了Pipfile、pip、click、requests和virtualenv,能够有效管理Python多个环境,各种第三方包及模块。

pipenv所解决的问题:

  • requirements.txt 依赖管理的局限
  • 多个项目依赖不同第三方库、包版本问题
  • 依赖分析

pipenv的特性:

  • pipenv集成了pip,virtualenv两者的功能,且完善了两者的一些缺陷。
  • 过去用virtualenv管理requirements.txt文件可能会有问题,Pipenv使用Pipfile和Pipfile.lock,后者存放将包的依赖关系,查看依赖关系是十分方便。
  • 各个地方使用了哈希校验,无论安装还是卸载包都十分安全,且会自动公开安全漏洞。
  • 通过加载.env文件简化开发工作流程。
  • 支持Python2 和 Python3,在各个平台的命令都是一样的。

可使用以下命令安装pipenv

pip3 install pipenv

接下来,通过使用以下命令创建一个新环境,在指定目录下创建虚拟环境, 会使用本地默认版本的python

pipenv install

如果要指定版本创建环境,可以使用如下命令

pipenv --two  # 使用当前系统中的Python2 创建环境
pipenv --three  # 使用当前系统中的Python3 创建环境
pipenv --python 3  # 指定使用Python3创建环境
pipenv --python 3.6  # 指定使用Python3.6创建环境
pipenv --python 2.7.14  # 指定使用Python2.7.14创建环境

激活虚拟环境

pipenv shell

删除虚拟环境

pipenv --rm

使用exit退出当前虚拟环境

Conda Environment

Anaconda(官方网站)就是可以便捷获取包且对包能够进行管理,同时对环境可以统一管理的发行版本。Anaconda包含了conda、Python在内的超过180个科学包及其依赖项。Anaconda也有自己的虚拟环境系统,称为conda。

可以通过以下命令创建虚拟环境

conda create --name environment_name python=3.6

激活虚拟环境

conda activate

conda环境的卸载

conda remove -n environment_name --all

Effie快捷键列表

Effie 快捷键列表

基本操作

  • Ctrl+Alt+, 打开设置
  • Ctrl+1 展开/收缩左边栏
  • Shift+Ctrl+N 新建文件夹
  • Ctrl+N 新建文稿
  • Ctrl+Up 打开上一篇文稿
  • Ctrl+Down 打开下一篇文稿
  • Ctrl+Delete 删除选中文稿
  • Shift+Ctrl+S 把文稿导出成 EffieSheet 文件
  • Ctrl+6 字数统计
  • Shift+Ctrl+F 在文稿列表里查找
  • F11 进入/退出全屏

大纲

  • Tab 降低层级
  • Shift+Tab 提升层级
  • Ctrl+` 打开思维导图
  • Ctrl+/ 折叠/展开大纲

思维导图

  • Ctrl++ 放大思维导图
  • Ctrl+- 缩小思维导图
  • Ctrl+0 还原思维导图默认大小
  • Enter 插入主题
  • Tab 插入子主题
  • Ctrl+S 保存成图片
  • Ctrl+/ 折叠/展开子主题
  • Ctrl+Z 撤销
  • Shift+Ctrl+Z 重做
  • Del 删除

编辑区快捷键

  • Ctrl+Z 撤销
  • Shift+Ctrl+Z 重做
  • Ctrl+A 全选
  • Ctrl+X 剪切
  • Ctrl+C 复制
  • Ctrl+V 粘贴
  • Ctrl+B 加粗
  • Ctrl+I 斜体
  • Ctrl+L 清除标志
  • Ctrl+F 查找
  • F3 查找下一个
  • Ctrl+H 查找并替换
  • → 光标向右移动一个字符
  • ← 光标向左移动一个字符
  • ↑ 光标向上移动一行
  • ↓ 光标向下移动一行
  • Home 光标移动到当前行行首
  • End 光标移动到当前行行尾
  • Ctrl+Home 光标移动到文章开头
  • Ctrl+End 光标移动到文章末尾

怎么管理自己的工作和生活?我尝试了Notion+滴答清单

日常浏览少数派时,遇到好的文章或者种草推荐,我都会把相关的链接发送到Notion。Notion的文章和视频记录库中就存放着大量文章,每天晚上打开Notion,一一阅读摘抄消化,形成完整的阅读闭环。突然,钉钉弹窗显示有新的Tapd task需要处理,点击查看task的大致内容,如果是需要敏捷开发流程的task直接丢进Notion的Tapd数据库,如果是其他简单的task则发送到滴答清单做Check。对于滴答清单,更重要的是记录日常琐事,比如取快递、朋友生日、打电话等。

这里提到了日常工作生活中会遇到的三大场景:

  • 工作
  • 提升(我更情愿说是输入,有输入才有输出)
  • 日常的生活杂事

我用Notion+滴答清单把工作生活安排的妥妥当当,轻松搞定。

滴答清单

滴答清单简洁易用效率高,全平台支持。可以通过微信创建任务,开启智能识别日期来设置提醒,使用番茄计时保持专注,从记录到完成,滴答清单10+个平台的30+功能,只为确保每一个环节都简单快捷。这是官方说的,我使用滴答清单本质工作:清单。

使用场景

  • 日常琐事
  • 人际交往
  • 工作中遇到的临时处理或者不需要经过敏捷开发流程的任务

Notion

Notion是一款提供笔记、任务、数据库、看板、维基、日历和提醒等组件的应用程序。你可以将这些组件连接起来,来创建自己的系统,用于知识管理、笔记记录、数据管理、项目管理等,另外官方和社区也提供了很多不错的模板可直接Duplicate。我用Notion替代了印象笔记(虽然我是印象笔记专业版的老用户,奈何印象笔记太不思进取了),目前Notion的主要使用场景如下:

  • 待读
  • 追剧
  • 输入计划(书单、新技能、博客文章等)
  • 工作中需要敏捷开发流程的task、工作日志
  • 每日一记
  • 其他资料

(这两款App均有全平台支持,滴答清单免费版有数量和功能限制,每月16月可获得全功能,当然按年付费更优惠些;Notion免费版有块限制,绑定教育邮箱可免费获得全功能)

我的工作生活流

结合自身需求和使用场景,整合了滴答清单和Notion,构建了我的工作生活流。这里秉承的原则是,Notion承担工作项目、自身输入和目标管理,而滴答清单是生活琐事和那些能较快处理的工作任务。

  1. 日常的生活杂事分发给滴答清单
  2. 工作中遇到的临时处理或者不需要经过敏捷开发流程的任务分发给滴答清单
  3. 工作中需要敏捷开发流程的任务,主要来自Tapd,分发给Notion
  4. 输入指网上冲浪的待读、待看或者是个人提升计划,像学习摄影、阅读书单等分发给Notion
  5. Notion 新增或更新item触发AutoMate同步条件
  6. AutoMate执行同步操作将Notion的item同步到Google Calendar
  7. Google Calendar将新建的日程通过Email发送到我的邮箱(可供检查同步是否成功)
  8. 滴答清单订阅Google Calendar

以上是主要实现的流程,能满足基本的三大场景。在苹果手机主屏幕上直接添加滴答清单的小组件,可以清晰的一览今天的任务。

另外如果你觉得滴答清单的小组件不美观,也可以使用ios日历应用订阅滴答清单和Google Calendar。但需要注意的是移动端的滴答清单默认是订阅了ios日历的。于是会出现这样的情况(滴答清单本身任务是A,Google Calendar是G):

滴答清单 = A + ios日历 + G

iso日历 = A + G

简单的数学运算后:滴答清单 = 2A + 2G,显示就会存在重复。这时候在滴答清单的iso日历中隐藏A和G,那么滴答清单 = A + G,显示就正常了。

或许你会有疑问,ios日志取消订阅Google Calendar,只订阅滴答清单,问题不就迎刃而解了吗?而现实是NO NO NO....,因为ios订阅滴答清单只会显示A,而订阅的G是不会显示的。ios日历小组件长这样,绿色是A,其他是G。

Notion同步到滴答清单

前面提到了AutoMate,来看看我的工作生活流中的5、6、7是怎么产生的。

背景

对于工作中确认需要敏捷开发流程的task或者大型项目,我希望能在滴答清单中看到它的Deadline,打开滴答清单或者手机小组件上可查看整体的工作安排,这一点Notion是做不到的。通常可以把任务分两次分发给滴答清单和Notion,但这里有一个痛点:如果Notion内task的Deadline发生变更,需要及时手动去修改滴答清单对应的task,由于相同的task分布在两个APP内,手动同步操作本身就很容易被各种事情打断遗忘。那么问题来了,有什么解办法能实现自动同步呢?于是开始寻求解决方案。

以往使用过IFTTT,它旨在帮助人们利用各网站的开放API,将Facebook、Twitter等各个网站或应用衔接,完成任务,使“每个人都可以成为整个互联网不用编程的程序员”。另外还有Zaiper和Automate也是提供类似的服务。

简单对比下Zaiper、Automate 和IFTTT。网页设计美观与否就一千个哈姆雷特了。但从功能角度上来说 ,IFTTT和Zaiper对于Notion只提供新增item的API,显然不满足我的需求。而Automate 功能上强大些,提供了新增item和更新属性的操作。同步时效上,在免费账户下,IFTTT最快,Automate 5分钟,Zapier 15分钟,最后我选择了AutoMate。

问题

由于Automate是国外服务,想直接从Notion同步到滴答清单,似乎不太可能。只能同步到TickTick(滴答清单海外版),但TickTick和滴答清单账号数据是不相通的。如果你直接使用TickTick会更方便,对于订阅了滴答清单高级版的我,当然要物尽其用,况且滴答清单可以通过微信创建任务,易用性上更好。既然不能直接硬钢,那就曲线救国。

解决办法

将Google Calendar当做中间件,从Notion同步到Google Calendar,滴答清单再订阅Google Calendar,就实现了Notion到滴答清单。有了思路,说干就干。

首先我在Google Calendar建立了2个日历:inbox和work。字面上就能理解inbox承担的是事件收集功能,而work就是有明确Deadline的事件集,inbox和work事件上不存在交集的。根据需求我在Automate中创建了3个自动化任务。

ANotionAInbox

Notion新增的item会直接新增进inbox,通常这些事件还没有明确的Deadline,Google Calendar新建日历事件的startTime和endTime设置为item的创建时间(CreatedTime),Location设置为item的id(是为了之后item属性更新能搜索到inbox新增事件)

UNotionDInboxAwork

一旦确定好Deadline时间,Notion的Deadline属性变更,根据item id在inbox搜索事件,获取事件id,再删除inbox事件,最后在work中新增事件。事件的startTime和endTime设置为item的Deadline,Location设置为item的id。

UNotionDWorkAWork

Notion的任何属性变更会触发搜索,这次是在work中进行搜索,获取事件id,更新事件。

Notion属性变更会同时触发UNotionDInboxAwork和UNotionDWorkAWork。

图中

  • 否(1):不是第一次属性变更,UNotionDInboxAWork执行失败,因为在第一次属性变更时inbox中对应的事件已删除;
  • 否(2):是第一次属性变更,如果不是Deadline属性,UNotionDInboxAWork执行失败,因为work中新增事件的startTime和endTime设置的是Deadline,而Notion新增item时Deadline是空的
  • 是(1):UNotionDWorkAWork在第一次属性变更时执行失败,work中还没有创建该事件

另外如果不是第一次属性变更,任何属性的变化,UNotionDWorkAWork都执行成功,如果是Deadline属性,会更新事件的startTime和endTime,其他属性不变。

同步任务执行成功后,都会发Email到指定账号。

Automate的注意事项:

  • 只能设定最多 5 個自动化任务(上方說明就代表 2个任务)
  • 1 个月最多只能同步 300 次(只要一个流程有触发成功,就算 1 次同步)
  • 触发同步的时间为 5 分钟 1 次

以上就是我的工作生活流,有很多地方存在bug,但基本满足我的需求,目前使用起来还是比较顺畅的,如果更好的想法欢迎大家分享。