m3u8 文件格式详解

简介

M3U8 是 Unicode 版本的 M3U,用 UTF-8 编码。”M3U” 和 “M3U8” 文件都是苹果公司使用的 HTTP Live Streaming(HLS) 协议格式的基础,这种协议格式可以在 iPhone 和 Macbook 等设备播放。

上述文字定义来自于维基百科。可以看到,m3u8 文件其实是 HTTP Live Streaming(缩写为 HLS) 协议的部分内容,而 HLS 是一个由苹果公司提出的基于 HTTP流媒体网络传输协议

HLS 的工作原理是把整个流分成一个个小的基于 HTTP 的文件来下载,每次只下载一些。当媒体流正在播放时,客户端可以选择从许多不同的备用源中以不同的速率下载同样的资源,允许流媒体会话适应不同的数据速率。在开始一个流媒体会话时,客户端会下载一个包含元数据的 extended M3U (m3u8) playlist文件,用于寻找可用的媒体流。
HLS 只请求基本的 HTTP 报文,与实时传输协议(RTP)不同,HLS 可以穿过任何允许 HTTP 数据通过的防火墙或者代理服务器。它也很容易使用内容分发网络来传输媒体流。

简而言之,HLS 是新一代流媒体传输协议,其基本实现原理为将一个大的媒体文件进行分片,将该分片文件资源路径记录于 m3u8 文件(即 playlist)内,其中附带一些额外描述(比如该资源的多带宽信息···)用于提供给客户端。客户端依据该 m3u8 文件即可获取对应的媒体资源,进行播放。

因此,客户端获取 HLS 流文件,主要就是对 m3u8 文件进行解析操作。

那么,下面就简单介绍下 m3u8 文件。

M3U8 文件简介

m3u8 文件实质是一个播放列表(playlist),其可能是一个媒体播放列表(Media Playlist),或者是一个主列表(Master Playlist)。但无论是哪种播放列表,其内部文字使用的都是 utf-8 编码。

当 m3u8 文件作为媒体播放列表(Meida Playlist)时,其内部信息记录的是一系列媒体片段资源,顺序播放该片段资源,即可完整展示多媒体资源。其格式如下所示:

#EXTM3U
#EXT-X-TARGETDURATION:10

#EXTINF:9.009,
http://media.example.com/first.ts
#EXTINF:9.009,
http://media.example.com/second.ts
#EXTINF:3.003,
http://media.example.com/third.ts

对于点播来说,客户端只需按顺序下载上述片段资源,依次进行播放即可。而对于直播来说,客户端需要 定时重新请求 该 m3u8 文件,看下是否有新的片段数据需要进行下载并播放。

当 m3u8 作为主播放列表(Master Playlist)时,其内部提供的是同一份媒体资源的多份流列表资源(Variant Stream)。其格式如下所示:

#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=150000,RESOLUTION=416x234,CODECS="avc1.42e00a,mp4a.40.2"
http://example.com/low/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=240000,RESOLUTION=416x234,CODECS="avc1.42e00a,mp4a.40.2"
http://example.com/lo_mid/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=440000,RESOLUTION=416x234,CODECS="avc1.42e00a,mp4a.40.2"
http://example.com/hi_mid/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=640000,RESOLUTION=640x360,CODECS="avc1.42e00a,mp4a.40.2"
http://example.com/high/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=64000,CODECS="mp4a.40.5"
http://example.com/audio/index.m3u8

该备用流资源指定了多种不同码率,不同格式的媒体播放列表,并且,该备用流资源也可同时提供不同版本的资源内容,比如不同语言的音频文件,不同角度拍摄的视屏文件等等。客户可以根据不同的网络状态选取合适码流的资源,并且最好根据用户喜好选择合适的资源内容。

更多详细内容,可查看:

以上,就是 m3u8 文件的大概内容。下面,我们就对 m3u8 内容格式进行讲解。

m3u8 文件格式简解

m3u8 的文件格式主要包含三方面内容:

  1. 文件播放列表格式定义:播放列表(Playlist,也即 m3u8 文件) 内容需严格满足规范定义所提要求。下面罗列一些主要遵循的条件:
  • m3u8 文件必须以 utf-8 进行编码,不能使用 Byte Order Mark(BOM)字节序, 不能包含 utf-8 控制字符(U+0000 ~ U_001F 和 U+007F ~ u+009F)。
  • m3u8 文件的每一行要么是一个 URI,要么是空行,要么就是以 # 开头的字符串。不能出现空白字符,除了显示声明的元素。
  • m3u8 文件中以 # 开头的字符串要么是注释,要么就是标签。标签以 #EXT 开头,大小写敏感。
  1. 属性列表(Attribute Lists):某些特定的标签的值为属性列表。标签后面的属性列表以 逗号 作为分隔符,分离出多组不带空格的 属性/值 对。
    属性/值 对的语法格式如下:

AttributeName=AttributeValue

其中:

  • 属性AttributeName是由 [A..Z],[0..9] 和 - 组成的不带引号的字符串。因此,属性AttributeName只能使用大写字母,不能使用小写字母,并且AttributeName=中间不能有空格,同理,=AttributeValue之间也不能有空格。
  • AttributeValue的只能取以下类型:
    • 十进制整型(decimal-interger):由 [0..9] 之间组成的十进制不带引号的字符串,范围为 0 ~ 2^{64}(18446744073709551615),字符长度为 1 ~ 20 之间。
    • 十六进制序列:由 [0..9] 和 [A..F] 且前缀为 0x 或 0X 组合成的不带引号的字符串。其序列的最大长度取决于他的属性名AttributeNames
    • 带符号十进制浮点型(signed-decimal-floating-point):由 [0..9],-.组合成的不带引号的字符串。
    • 字符串(quoted-string):由双引号包裹表示的字符串。其中,0xA,0xD 和 双引号"不能出现在该字符串中。该字符串区分大小写。
    • 可枚举字符串(enumerated-string):由AttributeName显示定义的一系列不带引号的字符串。该字符串不能包含双引号",逗号,和空白字符。
    • decimal-resolution:由字符x进行隔离的两个十进制整型数。第一个整型表示水平宽度大小,第二个整型数表示垂直方向高度大小(单位:像素)。
  1. 标签:标签用于指定 m3u8 文件的全局参数或在其后面的切片文件/媒体播放列表的一些信息。

标签的类型可分为五种类型:基础标签(Basic Tags)媒体片段类型标签(Media Segment Tags)媒体播放列表类型标签主播放列表类型标签播放列表类型标签。其具体内容如下所示:

  • 基础标签(Basic Tags):可同时适用于媒体播放列表(Media Playlist)和主播放列表(Master Playlist)。具体标签如下:
    • EXTM3U:表明该文件是一个 m3u8 文件。每个 M3U 文件必须将该标签放置在第一行。
    • EXT-X-VERSION:表示 HLS 的协议版本号,该标签与流媒体的兼容性相关。该标签为全局作用域,使能整个 m3u8 文件;每个 m3u8 文件内最多只能出现一个该标签定义。如果 m3u8 文件不包含该标签,则默认为协议的第一个版本。
  • 媒体片段类型标签(Media Segment Tags):每个切片 URI 前面都有一系列媒体片段标签对其进行描述。有些片段标签只对其后切片资源有效;有些片段标签对其后所有切片都有效,直到后续遇到另一个该标签描述。媒体片段类型标签不能出现在主播放列表(Master Playlist)中。具体标签如下:
    • EXTINF:表示其后 URL 指定的媒体片段时长(单位为秒)。每个 URL 媒体片段之前必须指定该标签。该标签的使用格式为:

      #EXTINF:<duration>,[<title>]
      

      其中:

      • duration:可以为十进制的整型或者浮点型,其值必须小于或等于 EXT-X-TARGETDURATION 指定的值。
        注:建议始终使用浮点型指定时长,这可以让客户端在定位流时,减少四舍五入错误。但是如果兼容版本号 EXT-X-VERSION 小于 3,那么必须使用整型。
    • EXT-X-BYTERANGE:该标签表示接下来的切片资源是其后 URI 指定的媒体片段资源的局部范围(即截取 URI 媒体资源部分内容作为下一个切片)。该标签只对其后一个 URI 起作用。其格式为:

      #EXT-X-BYTERANGE:<n>[@<o>]
      

      其中:

      • n是一个十进制整型,表示截取片段大小(单位:字节)。
      • 可选参数o也是一个十进制整型,指示截取起始位置(以字节表示,在 URI 指定的资源开头移动该字节位置后进行截取)。
        如果o未指定,则截取起始位置从上一个该标签截取完成的下一个字节(即上一个n+o+1)开始截取。
        如果没有指定该标签,则表明的切分范围为整个 URI 资源片段。
        注:使用 EXT-X-BYTERANGE 标签要求兼容版本号 EXT-X-VERSION 大于等于 4。
    • EXT-X-DISCONTINUITY:该标签表明其前一个切片与下一个切片之间存在中断。其格式为:

      #EXT-X-DISCONTINUITY
      

      当以下任一情况变化时,必须使用该标签:

      • 文件格式(file format)
      • 数字(number),类型(type),媒体标识符(identifiers of tracks)
      • 时间戳序列(timestamp sequence)

      当以下任一情况变化时,应当使用该标签:

      • 编码参数(encoding parameters)
      • 编码序列(encoding sequence)

      注:EXT-X-DISCONTINUITY 的一个经典使用场景就是在视屏流中插入广告,由于视屏流与广告视屏流不是同一份资源,因此在这两种流切换时使用 EXT-X-DISCONTINUITY 进行指明,客户端看到该标签后,就会处理这种切换中断问题,让体验更佳。
      更多详细内容,请查看:Incorporating Ads into a Playlist

    • EXT-X-KEY:媒体片段可以进行加密,而该标签可以指定解密方法。
      该标签对所有 媒体片段 和 由标签 EXT-X-MAP 声明的围绕其间的所有 媒体初始化块(Meida Initialization Section) 都起作用,直到遇到下一个 EXT-X-KEY(若 m3u8 文件只有一个 EXT-X-KEY 标签,则其作用于所有媒体片段)。
      多个 EXT-X-KEY 标签如果最终生成的是同样的秘钥,则他们都可作用于同一个媒体片段。
      该标签使用格式为:

      #EXT-X-KEY:<attribute-list>
      

      属性列表可以包含如下几个键:

      • METHOD:该值是一个可枚举的字符串,指定了加密方法。
        该键是必须参数。其值可为NONEAES-128SAMPLE-AES当中的一个。
        其中:
        NONE:表示切片未进行加密(此时其他属性不能出现);
        AES-128:表示表示使用 AES-128 进行加密。
        SAMPLE-AES:意味着媒体片段当中包含样本媒体,比如音频或视频,它们使用 AES-128 进行加密。这种情况下 IV 属性可以出现也可以不出现。
      • URI:指定密钥路径。
        该密钥是一个 16 字节的数据。
        该键是必须参数,除非 METHODNONE
      • IV:该值是一个 128 位的十六进制数值。
        AES-128 要求使用相同的 16字节 IV 值进行加密和解密。使用不同的 IV 值可以增强密码强度。
        如果属性列表出现 IV,则使用该值;如果未出现,则默认使用媒体片段序列号(即 EXT-X-MEDIA-SEQUENCE)作为其 IV 值,使用大端字节序,往左填充 0 直到序列号满足 16 字节(128 位)。
      • KEYFORMAT:由双引号包裹的字符串,标识密钥在密钥文件中的存储方式(密钥文件中的 AES-128 密钥是以二进制方式存储的16个字节的密钥)。
        该属性为可选参数,其默认值为"identity"
        使用该属性要求兼容版本号 EXT-X-VERSION 大于等于 5。
      • KEYFORMATVERSIONS:由一个或多个被/分割的正整型数值构成的带引号的字符串(比如:"1""1/2""1/2/5")。
        如果有一个或多特定的 KEYFORMT 版本被定义了,则可使用该属性指示具体版本进行编译。
        该属性为可选参数,其默认值为"1"
        使用该属性要求兼容版本号 EXT-X-VERSION 大于等于 5。
    • EXT-X-MAP:该标签指明了获取媒体初始化块(Meida Initialization Section)的方法。
      该标签对其后所有媒体片段生效,直至遇到另一个 EXT-X-MAP 标签。
      其格式为:

      #EXT-X-MAP:<attribute-list>
      

      其属性列表取值范围如下:

      • URI:由引号包裹的字符串,指定了包含媒体初始化块的资源的路径。该属性为必选参数。
      • BYTERANGE:由引号包裹的字符串,指定了媒体初始化块在 URI 指定的资源的位置(片段)。
        该属性指定的范围应当只包含媒体初始化块。
        该属性为可选参数,如果未指定,则表示 URI 指定的资源就是全部的媒体初始化块。
    • EXT-X-PROGRAM-DATE-TIME:该标签使用一个绝对日期/时间表明第一个样本片段的取样时间。
      其格式为:

      #EXT-X-PROGRAM-DATE-TIME:<date-time-msec>
      

      其中,date-time-msec是一个 ISO/IEC 8601:2004 规定的日期格式,形如:YYYY-MM-DDThh:mm:ss.SSSZ。

    • EXT-X-DATERANGE:该标签定义了一系列由属性/值对组成的日期范围。
      其格式为:

      #EXT-X-DATERANGE:<attribute-list>
      

      其属性列表取值如下:

      • ID:双引号包裹的唯一指明日期范围的标识。
        该属性为必选参数。
      • CLASS:双引号包裹的由客户定义的一系列属性与与之对应的语意值。
        所有拥有同一 CLASS 属性的日期范围必须遵守对应的语意。
        该属性为可选参数。
      • START-DATE:双引号包裹的日期范围起始值。
        该属性为必选参数。
      • END-DATE:双引号包裹的日期范围结束值。
        该属性值必须大于或等于 START-DATE
        该属性为可选参数。
      • DURATION:日期范围的持续时间是一个十进制浮点型数值类型(单位:秒)。
        该属性值不能为负数。
        当表达立即时间时,将该属性值设为 0 即可。
        该属性为可选参数。
      • PLANNED-DURATION:该属性为日期范围的期望持续时长。
        其值为一个十进制浮点数值类型(单位:秒)。
        该属性值不能为负数。
        在预先无法得知真实持续时长的情况下,可使用该属性作为日期范围的期望预估时长。
        该属性为可选参数。
    • X-<client-attribute>X-前缀是预留给客户端自定义属性的命名空间。
      客户端自定义属性名时,应当使用反向 DNS(reverse-DNS)语法来避免冲突。
      自定义属性值必须是使用双引号包裹的字符串,或者是十六进制序列,或者是十进制浮点数,比如:X-COM-EXAMPLE-AD-ID="XYZ123"
      该属性为可选参数。
    • SCTE35-CMD, SCTE35-OUT, SCTE35-IN:用于携带 SCET-35 数据。
      该属性为可选参数。
    • END-ON-NEXT:该属性值为一个可枚举字符串,其值必须为YES
      该属性表明达到该范围末尾,也即等于后续范围的起始位置 START-DATE。后续范围是指具有相同 CLASS 的,在该标签 START-DATE 之后的具有最早 START-DATE 值的日期范围。
      该属性时可选参数。
  • 媒体播放列表类型标签:媒体播放列表标签为 m3u8 文件的全局参数信息。
    这些标签只能在 m3u8 文件中至多出现一次。
    媒体播放列表(Media Playlist)标签不能出现在主播放列表(Master Playlist)中。
    媒体播放列表具体标签如下所示:

    • EXT-X-TARGETDURATION:表示每个视频分段最大的时长(单位秒)。
      该标签为必选标签。
      其格式为:

      #EXT-X-TARGETDURATION:<s>
      

      其中:参数s表示目标时长(单位:秒)。

    • EXT-X-MEDIA-SEQUENCE:表示播放列表第一个 URL 片段文件的序列号。
      每个媒体片段 URL 都拥有一个唯一的整型序列号。
      每个媒体片段序列号按出现顺序依次加 1。
      如果该标签未指定,则默认序列号从 0 开始。
      媒体片段序列号与片段文件名无关。
      其格式为:

      #EXT-X-MEDIA-SEQUENCE:<number>
      

      其中:参数number即为切片序列号。

    • EXT-X-DISCONTINUITY-SEQUENCE:该标签使能同步相同流的不同 Rendition 和 具备 EXT-X-DISCONTINUITY 标签的不同备份流。
      其格式为:

      #EXT-X-DISCONTINUITY-SEQUENCE:<number>
      

      其中:参数number为一个十进制整型数值。
      如果播放列表未设置 EXT-X-DISCONTINUITY-SEQUENCE 标签,那么对于第一个切片的中断序列号应当为 0。

    • EXT-X-ENDLIST:表明 m3u8 文件的结束。
      该标签可出现在 m3u8 文件任意位置,一般是结尾。
      其格式为:

      #EXT-X-ENDLIST
      
    • EXT-X-PLAYLIST-TYPE:表明流媒体类型。全局生效。
      该标签为可选标签。
      其格式为:

      #EXT-X-PLAYLIST-TYPE:<type-enum>
      

      其中:type-enum可选值如下:

      • VOD:即 Video on Demand,表示该视屏流为点播源,因此服务器不能更改该 m3u8 文件;
      • EVENT:表示该视频流为直播源,因此服务器不能更改或删除该文件任意部分内容(但是可以在文件末尾添加新内容)。
        注:VOD 文件通常带有 EXT-X-ENDLIST 标签,因为其为点播源,不会改变;而 EVEVT 文件初始化时一般不会有 EXT-X-ENDLIST 标签,暗示有新的文件会添加到播放列表末尾,因此也需要客户端定时获取该 m3u8 文件,以获取新的媒体片段资源,直到访问到 EXT-X-ENDLIST 标签才停止)。
    • EXT-X-I-FRAMES-ONLY:该标签表示每个媒体片段都是一个 I-frameI-frames 帧视屏编码不依赖于其他帧数,因此可以通过 I-frame 进行快速播放,急速翻转等操作。
      该标签全局生效。
      其格式为:

      #EXT-X-I-FRAMES-ONLY
      

      如果播放列表设置了 EXT-X-I-FRAMES-ONLY,那么切片的时长(EXTINF 标签的值)即为当前切片 I-frame 帧开始到下一个 I-frame 帧出现的时长。
      媒体资源如果包含 I-frame 切片,那么必须提供媒体初始化块或者通过 EXT-X-MAP 标签提供媒体初始化块的获取途径,这样客户端就能通过这些 I-frame 切片以任意顺序进行加载和解码。
      如果 I-frame 切片设置了 EXT-BYTERANGE,那么就绝对不能提供媒体初始化块。
      使用 EXT-X-I-FRAMES-ONLY 要求的兼容版本号 EXT-X-VERSION 大于等于 4。

  • 主播放列表类型标签:主播放列表(Master Playlist)定义了备份流,多语言翻译流和其他全局参数。
    主播放列表标签绝不能出现在媒体播放列表(Media Playlist)中。
    其具体标签如下:

    • EXT-X-MEDIA:用于指定相同内容的可替换的多语言翻译播放媒体列表资源。
      比如,通过三个 EXT-X-MEIDA 标签,可以提供包含英文,法语和西班牙语版本的相同内容的音频资源,或者通过两个 EXT-X-MEDIA 提供两个不同拍摄角度的视屏资源。
      其格式为:

      #EXT-X-MEDIA:<attribute-list>
      

      其中,属性列表取值范围如下:

      • TYPE:该属性值为一个可枚举字符串。
        其值有如下四种:AUDIOVIDEOSUBTITLESCLOSED-CAPTIONS
        通常使用的都是CLOSED-CAPTIONS
        该属性为必选参数。
      • URI:双引号包裹的媒体资源播放列表路径。
        如果 TYPE 属性值为 CLOSED-CAPTIONS,那么则不能提供 URI
        该属性为可选参数。
      • GROUP-ID:双引号包裹的字符串,表示多语言翻译流所属组。
        该属性为必选参数。
      • LANGUAGE:双引号包裹的字符串,用于指定流主要使用的语言。
        该属性为可选参数。
      • ASSOC-LANGUAGE:双引号包裹的字符串,其内包含一个语言标签,用于提供多语言流的其中一种语言版本。
        该参数为可选参数。
      • NAME:双引号包裹的字符串,用于为翻译流提供可读的描述信息。
        如果设置了 LANGUAGE 属性,那么也应当设置 NAME 属性。
        该属性为必选参数。
      • DEFAULT:该属性值为一个可枚举字符串。
        可选值为YESNO
        该属性未指定时默认值为NO
        如果该属性设为YES,那么客户端在缺乏其他可选信息时应当播放该翻译流。
        该属性为可选参数。
      • AUTOSELECT:该属性值为一个可枚举字符串。
        其有效值为YESNO
        未指定时,默认设为NO
        如果该属性设置YES,那么客户端在用户没有显示进行设置时,可以选择播放该翻译流,因为其能配置当前播放环境,比如系统语言选择。
        如果设置了该属性,那么当 DEFAULT 设置YES时,该属性也必须设置为YES
        该属性为可选参数。
      • FORCED:该属性值为一个可枚举字符串。
        其有效值为YESNO
        未指定时,默认设为NO
        只有在设置了 TYPESUBTITLES 时,才可以设置该属性。
        当该属性设为YES时,则暗示该翻译流包含重要内容。当设置了该属性,客户端应当选择播放匹配当前播放环境最佳的翻译流。
        当该属性设为NO时,则表示该翻译流内容意图用于回复用户显示进行请求。
        该属性为可选参数。
      • INSTREAM-ID:由双引号包裹的字符串,用于指示切片的语言(Rendition)版本。
        TYPE 设为 CLOSED-CAPTIONS 时,必须设置该属性。
        其可选值为:"CC1", "CC2", "CC3", "CC4""SERVICEn"n的值为 1~63)。
        对于其他 TYPE 值,该属性绝不能进行设置。
      • CHARACTERISTICS:由双引号包裹的由一个或多个由逗号分隔的 UTI 构成的字符串。
        每个 UTI 表示一种翻译流的特征。
        该属性可包含私有 UTI。
        该属性为可选参数。
      • CHANNELS:由双引号包裹的有序,由反斜杠/分隔的参数列表组成的字符串。
        所有音频 EXT-X-MEDIA 标签应当都设置 CHANNELS 属性。
        如果主播放列表包含两个相同编码但是具有不同数目 channed 的翻译流,则必须设置 CHANNELS 属性;否则,CHANNELS 属性为可选参数。
    • EXT-X-STREAM-INF:该属性指定了一个备份源。该属性值提供了该备份源的相关信息。
      其格式为:

      #EXT-X-STREAM-INF:<attribute-list>
      <URI>
      

      其中:

      • URI 指定的媒体播放列表携带了该标签指定的翻译备份源。
        URI 为必选参数。
      • EXT-X-STREAM-INF 标签的参数属性列表有如下选项:
        • BANDWIDTH:该属性为每秒传输的比特数,也即带宽。代表该备份流的巅峰速率。
          该属性为必选参数。
        • AVERAGE-BANDWIDTH:该属性为备份流的平均切片传输速率。
          该属性为可选参数。
        • CODECS:双引号包裹的包含由逗号分隔的格式列表组成的字符串。
          每个 EXT-X-STREAM-INF 标签都应当携带 CODECS 属性。
        • RESOLUTION:该属性描述备份流视屏源的最佳像素方案。
          该属性为可选参数,但对于包含视屏源的备份流建议增加该属性设置。
        • FRAME-RATE:该属性用一个十进制浮点型数值作为描述备份流所有视屏最大帧率。
          对于备份流中任意视屏源帧数超过每秒 30 帧的,应当增加该属性设置。
          该属性为可选参数,但对于包含视屏源的备份流建议增加该属性设置。
        • HDCP-LEVEL:该属性值为一个可枚举字符串。
          其有效值为TYPE-0NONE
          值为TYPE-0表示该备份流可能会播放失败,除非输出被高带宽数字内容保护(HDCP)。
          值为NONE表示流内容无需输出拷贝保护。
          使用不同程度的 HDCP 加密备份流应当使用不同的媒体加密密钥。
          该属性为可选参数。在缺乏 HDCP 可能存在播放失败的情况下,应当提供该属性。
        • AUDIO:属性值由双引号包裹,其值必须与定义在主播放列表某处的设置了 TYPE 属性值为 AUDIOEXT-X-MEDIA 标签的 GROUP-ID 属性值相匹配。
          该属性为可选参数。
        • VIDEO:属性值由双引号包裹,其值必须与定义在主播放列表某处的设置了 TYPE 属性值为 VIDEOEXT-X-MEDIA 标签的 GROUP-ID 属性值相匹配。
          该属性为可选参数。
        • SUBTITLES:属性值由双引号包裹,其值必须与定义在主播放列表某处的设置了 TYPE 属性值为 SUBTITLESEXT-X-MEDIA 标签的 GROUP-ID 属性值相匹配。
          该属性为可选参数。
        • CLOSED-CAPTIONS:该属性值可以是一个双引号包裹的字符串或NONE
          如果其值为一个字符串,则必须与定义在主播放列表某处的设置了 TYPE 属性值为 CLOSED-CAPTIONSEXT-X-MEDIA 标签的 GROUP-ID 属性值相匹配。
          如果其值为NONE,则所有的 ext-x-stream-inf 标签必须同样将该属性设置NONE,表示主播放列表备份流均没有关闭的标题。对于某个备份流具备关闭标题,另一个备份流不具备关闭标题可能会触发播放中断。
          该属性为可选参数。
    • EXT-X-I-FRAME-STREAM-INF:该标签表明媒体播放列表文件包含多种媒体资源的 I-frame 帧。
      其格式为:

      #EXT-X-I-FRAME-STREAM-INF:<attribute-list>
      

      该标签的属性列表包含了 EXT-X-I-FRAME-STREAM-INF 标签同样的属性列表选项,除了 FRAME-RATEAUDIOSUBTITLESCLOSED-CAPTIONS。除此之外,其他的属性还有:

      • URI:该属性值由双引号包裹的字符串,指示了 I-frame 媒体播放列表文件的路径,该媒体播放列表文件必须包含 EXT-X-I-FRAMES-ONLY 标签。
    • EXT-X-SESSION-DATA:该标签允许主播放列表携带任意 session 数据。
      该标签为可选参数。
      其格式为:

      #EXT-X-SESSION-DATA:<attribute-list>
      

      其中,其参数属性列表值如下可选项:

      • DATA-ID:由双引号包裹的字符串,代表一个特定的数据值。
        该属性应当使用反向 DNS 进行命名,如"com.example.movie.title"。然而,由于没有中央注册机构,所以可能出现冲突情况。
        该属性为必选参数。
      • VALUE:该属性值为一个双引号包裹的字符串,其包含 DATA-ID 指定的值。
        如果设置了 LANGUAGE,则 VALUE 应当包含一个用该语言书写的可读字符串。
      • URI:由双引号包裹的 URI 字符串。由该 URI 指示的资源必选使用 JSON 格式,否则,客户端可能会解析失败。
      • LANGUAGE:由双引号包裹的,包含一个语言标签的字符串。指示了 VALUE 所使用的语言。
  • EXT-X-SESSION-KEY:该标签允许主播放列表(Master Playlist)指定媒体播放列表(Meida Playlist)的加密密钥。这使得客户端可以预先加载这些密钥,而无需从媒体播放列表中获取。
    该标签为可选参数。
    其格式为:

    #EXT-X-SESSION-KEY:<attribute-list>
    

    其属性列表与 EXT-X-KEY 相同,除了 METHOD 属性的值必须不为NONE

  • 播放列表类型标签:以下标签可同时设置于主播放列表(Master Playlist)和媒体播放列表(Media Playlist)中。
    但是对于在主播放列表中设置了的标签,不应当再次设置在主播放列表指向的媒体播放列表中。
    同时出现在两者播放列表的相同标签必须具备相同的值。这些标签在播放列表中不能出现多次(只能使用一次)。具体标签如下所示:

    • EXT-X-INDEPENDENT-SEGMENTS:该标签表明对于一个媒体片段中的所有媒体样本均可独立进行解码,而无须依赖其他媒体片段信息。
      该标签对列表内所有媒体片段均有效。
      其格式为:

      #EXT-X-INDEPENDENT-SEGMENTS
      

      如果该标签出现在主播放列表中,则其对所有媒体播放列表的所有媒体片段都生效。

    • EXT-X-START:该标签表示播放列表播放起始位置。
      默认情况下,客户端开启一个播放会话时,应当使用该标签指定的位置进行播放。
      该标签为可选标签。
      其格式为:

      #EXT-X-START:<attribute-list>
      

      其参数属性列表的取值范围如下:

      • TIME-OFFSET:该属性值为一个带符号十进制浮点数(单位:秒)。
        一个正数表示以播放列表起始位置开始的时间偏移量。
        一个负数表示播放列表上一个媒体片段最后位置往前的时间偏移量。
        该属性的绝对值应当不超过播放列表的时长。如果超过,则表示到达文件结尾(数值为正数),或者达到文件起始(数值为负数)。
        如果播放列表不包含 EXT-X-ENDLIST 标签,那么 TIME-OFFSET 属性值不应当在播放文件末尾三个切片时长之内。
      • PRECISE:该值为一个可枚举字符串。
        有效的取值为YESNO
        如果值为YES,客户端应当播放包含 TIME-OFFSET 的媒体片段,但不要渲染该块内优先于 TIME-OFFSET 的样本块。
        如果值为NO,客户端应当尝试渲染在媒体片段内的所有样本块。
        该属性为可选参数,未指定则认为NO

到此,m3u8 相关的标签我们已经完全介绍完毕。

下面我们再简单介绍下资源文件的获取具体操作。

上文提到,m3u8 文件要么是媒体播放列表(Meida Playlist),要么是主播放列表(Master Playlist)。但无论是哪种列表,其有效内容均由两部分结构组成:

  • #EXT 开头的为标签信息,作为对媒体资源的进一步描述;
  • 剩余的为资源信息,要么是片段资源(Media Playlist)路径,要么是 m3u8 资源(Master Playlist)路径;

我们先简单介绍下 m3u8 文件媒体片段的表示方法:

  • m3u8 文件中,媒体片段可以采用全路径表示。如下所示:

#EXTINF:10.0,
http://example.com/movie1/fileSequenceA.ts

这样,获取资源片段的路径就是 m3u8 文件内指定的路径,即:http://example.com/movie1/fileSequenceA.ts

  • m3u8 文件中,媒体片段还可以使用相对路径表示。如下所示:

#EXTINF:10.0,
fileSequenceA.ts

这表示片段文件的路径是相对于 m3u8 文件路径的,即假设当前 m3u8 的路径为:https://127.0.0.1/hls/m3u8,那么,片段文件 fileSequenceA.ts 的路径即为:https://127.0.0.1/hls/fileSequenceA.ts

尽管可以在 m3u8 文件中使用绝对路径指定媒体片段资源路径,但是更好的选择是使用相对路径。相对路径相较于绝对路径更轻便,同时是相对于 m3u8 文件的 URL。相比之下,绝对路径增加了 m3u8 文件内容(更多字符),增大了文件内容,同时也增大了网络传输量。

其余一些注意事项

  • 有两种请求 m3u8 播放列表的方法:一是通过 m3u8 的 URI 进行请求,则该文件必须以 .m3u8 或 .m3u 结尾;
    二是通过 HTTP 进行请求,则请求头Content-Type必须设置为 application/vnd.apple.mpegurl或者audio/mpegurl
  • 空行和注释行在解析时都忽略。
  • 媒体播放列表(Media Playlist)的流资源总时长就是各切片资源的时长之和。
  • 每个切片的码率(bit rate)就是切片的大小除以它对应的时长(EXTINF 指定的时长)。
  • 一个标签的属性列表的同一个属性AttributeName只能出现一次。
  • EXT-X-TARGETDURATION 指定的时长绝对不能进行更改。通常该值指定的时长为 10 秒。
  • 对于指定了 EXT-X-I-FRAMES-ONLY 且 第一个媒体片段(或者第一个尾随 EXT-X-DISCONTINUITY 的片段)其资源没有立即携带媒体初始化块的切片,应当增加使用标签 EXT-X-MAP 指定媒体初始化块获取途径。
  • 使用 EXT-X-MAP 标签内含标签 EXT-X-I-FRAMES-ONLY 要求的兼容版本号 EXT-X-VERSION 要大于等于 5;只使用 EXT-X-MAP 要求的兼容版本号要大于等于 6。
  • 由标签 EXT-X-MAP 声明的媒体初始化块可使用 AES-128 方法进行加密,此时,作用于 EXT-X-MAP 标签的 EXT-X-KEY 标签必须设置 IV 属性。
  • 带有属性 END-ON-NEXT=YES 的标签 EXT-X-DATERANGE 必须携带 CLASS 属性,但不能携带 DURATIONEND-DATE 属性。其余带有相同 CLASS 的标签 EXT-X-DATERANGE 不能指定重叠的日期范围。
  • 日期范围如果未指明 DURATIONEND_DATE,END-ON-NEXT=YES 属性时,则其时长(duration)未知,即使其设置了 PLANNED-DURATION 属性。
  • 如果播放列表设置了 EXT-X-DATERANGE 标签,则必须同时设置 EXT-X-PROGRAM-DATE-TIME 标签。
  • 如果播放列表设置了拥有相同 ID 属性值的两个 EXT-X-DATERANGE 标签,则对于相同的属性名,在这两个 EXT-X-DATERANGE 中对应的值必须一致。
  • 如果 EXT-X-DATERANGE 同时设置了 DURATIONEND-DATE 属性,则 END-DATE 属性值必须等于 START-DATE 属性值加上 DURATION 属性值。
  • EXT-X-MEDIA-SEQUENCE 标签必须出现在播放列表第一个切片之前。
  • EXT-X-DISCONTINUITY-DEQUENCE 标签必须出现在播放列表第一个切片之前。
  • EXT-X-DISCONTINUITY-DEQUENCE 标签必须出现在任意 EXT-X-DISCONTINUITY 标签之前。
  • m3u8 文件如果没有设置 EXT-X-PLAYLIST-TYPE 标签,那么播放列表可以随时进行更改。比如,可以更新或删除播放列表中的媒体片段。
  • 每个 EXT-X-I-FRAME-STREAM-INF 标签必须包含一个 BANDWIDTHURI 属性。
  • 每个 EXT-X-SESSION-DATA 标签都必须包含一个 VALUEURI 属性,但不能同时包含两者。
  • 一个播放列表可以包含多个携带相同 DATA-ID 属性的 EXT-X-SESSION-DATA 标签。但是不能包含多个携带相同 DATA-ID 和相同 LANGUAGE 属性的 EXT-X-SESSION-DATA 标签。
  • 如果设置了 EXT-X-SESSION-KEY,那么其 METHODKEYFORMATKEYFORMATVERSIONS 属性值必须与任意相同 URIEXT-X-KEY 标签值相同。
  • 如果多份备用流或者多语言流使用相同的加密密钥和格式,则应当设置 EXT-X-SESSION-KEY 标签。
  • 主播放列表必须不能设置多个具有相同 METHODURIIVKEYFORMATKEYFORMATVERSIONS 属性值得 EXT-X-SESSION-KEY 标签。

附录

作者:Whyn
链接:https://www.jianshu.com/p/e97f6555a070
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

hugegraph图数据库索引详解

前言

在《技术文章之二 hugegraph图数据库概念详解》中我们介绍过IndexLabel,它是索引的元数据,用来描述对顶点/边的属性建立的索引。本文将对hugegraph中的索引做一个较为深入的介绍,并给出每一种索引的适用场景和使用方法的样例。

hugegraph的查询系统是以索引为基础建立的,如果没有索引,hugegraph只能做一些非常简单的查询,比如不带任何条件的查询:g.V()g.E();或者是能够优化为Id查询的条件查询,比如顶点Id策略为PRIMARY_KEY时的主属性(主索引)查询,查询种类十分有限。一旦创建了足够的索引,hugegraph就可以做各种各样复杂的查询了。

hugegraph包含三种索引,分别是:二级索引、范围索引、全文索引,二级索引又可分为单列索引和组合索引,还有一种能联合多种索引的联合索引。给了这么多名次,大家肯定有些头大,不用担心,大家不用太在乎这些索引的名称,暂时知道有多种索引即可,后面阅读完全文,了解这些索引的适用场景和用法即可。

二级索引

二级索引是最常见的索引类型,当用户对某个属性的查询总是“相等”判断时可以使用二级索引二级索引适合建立在大多数普通的属性上,比如:“姓名”、“职业”、“品牌”等容易准确描述的离散值。注意这里的两个措辞:“容易准确描述”和“离散值”并非专业描述,而是用于区别后面的范围索引和全文索引的。

  • “容易准确描述”指用户通常能完整的给出要查找的属性的值,而不是部分的,模糊不清的,比如某人的地址,一大串经常记不住,只能给出“北京市”、“某某小区”这样的部分信息;
  • “离散值”指的是属性的值是一个一个可以罗列的,就像一个一个整数值一样,而不是可以无限细分的浮点数,比如权重、得分这样值。

注意:上面的两个措辞只是一种一般性的描述,可能有些场景下,大的地址已经被限定在“北京市海淀区”里,那地址值就是一些简短的词也不足为奇了,此时也变成了“容易准确描述”的;“离散值”在不同场景下也有不同的定义,所以大家要根据具体的业务场景区分,切勿认死理。

hugegraph中二级索引的逻辑存储结构如下:

field_values | index_label_id | element_ids
  • field_values: 属性的值,可以是单个属性,也可以是多个属性拼接而成
  • index_label_id: 索引标签的Id
  • element_ids: 顶点或边的Id

各种后端具体的“表结构”都按照该逻辑结构设计。

二级索引允许在多个属性上建立索引,在单个属性上建立的索引称为单列索引,在多个属性上建立的索引称之为组合索引,下面分别介绍。

单列索引

先创建一个单列索引标签(元数据):

  1. // 创建索引类型:”personByName”,可以按“name”属性的值快速查询对应的“person”顶点
  2. schema.indexLabel(“personByName”)
  3. .onV(“person”)
  4. .by(“name”)
  5. .secondary()
  6. .create();

再插入几个顶点,有两个顶点的“name”相同:

  1. graph.addVertex(T.label, “person”, T.id, “p1”, “name”, “marko”);
  2. graph.addVertex(T.label, “person”, T.id, “p2”, “name”, “marko”);
  3. graph.addVertex(T.label, “person”, T.id, “p3”, “name”, “josh”);

我们以cassandra后端为例(下同)展示二级索引表的存储结构:

field_values | index_label_id | element_ids
————-±—————±————
marko | 1 | Sp1
marko | 1 | Sp2
josh | 1 | Sp3

表结构中,field_values作为primary key列,index_label_idelement_ids都作为cluster key,这样才能依次存储相同属性的element_id

当用户的查询语句中包含“name”=“marko”这样的条件时,就会命中前面两条记录得到两个element_id,然后再用这两个element_id去顶点表做Id查询得到顶点实体。

在真正的查询流程中,肯定是会先找到index_label_id的,然后用field_valuesindex_label_id一起定位,但是找index_label_id不是这里的重点,一笔带过。

组合索引

先创建一个组合索引标签:

  1. // 创建索引类型:”personByNameAndAge”,可以按“name”和”age”属性的值快速查询对应的“person”顶点
  2. schema.indexLabel(“personByNameAndAge”)
  3. .onV(“person”)
  4. .by(“name”, “age”)
  5. .secondary()
  6. .ifNotExist()
  7. .create();

再插入几个顶点:

  1. graph.addVertex(T.label, “person”, T.id, “p1”, “name”, “marko”, “age”, 29);
  2. graph.addVertex(T.label, “person”, T.id, “p2”, “name”, “vadas”, “age”, 29);
  3. graph.addVertex(T.label, “person”, T.id, “p3”, “name”, “josh”, “age”, 32);

然后再看存储结构:

field_values | index_label_id | element_ids
————-±—————±————
marko | 1 | Sp1
marko | 1 | Sp2
marko!29 | 1 | Sp1
josh!32 | 1 | Sp3
josh | 1 | Sp3
marko!27 | 1 | Sp2

这时我们发现:我只插入了3个顶点,但是一共有6条索引记录,这是为什么呢?

因为组合索引存储时会把多个属性值按顺序拼接在一起作为新的属性值,并且会将该属性值的所有前缀也都存储一份。以第一个顶点为例,它的“name”为“marko”,“age”为29,拼接在一起的新属性值是“marko!29”,会作为一条索引记录存储;然后它的前缀是“marko”,也会存储一份。

以此类推,如果这里创建的属性标签是建立在三个属性上的:“name”、“age”、“city”,假设“city”的值为“Beijing”,那一个顶点会对应三条索引,其field_values分别是“marko”、“marko!29”和“marko!29!Beijing”。

在像rocksdb和hbase这样的后端中,由于rowkey支持前缀匹配,则不会存储这么多的索引记录。

这样的存储结构只提供了前缀匹配的能力,所以当用户查询条件只包含“name”=“marko”时,可以命中前面两条记录;当查询条件包含“name”=“marko”和“age”=29时,可以命中第三条记录;但当查询条件中只包含“age”=29时,则无法命中任何一条记录。所以:创建索引标签时,组合索引中的属性顺序是很重要的。

范围索引

当用户的查询语义是:某属性值大于、小于、大于等于、小于等于、等于某个界限,或者属性值属于某个区间时,适合使用范围索引。比如:“年龄”、“价格”、“得分”等取值比较连续的属性。

要注意的是:该属性必须可以做比较,在Java中也就是实现了Comparable接口,所以Number类型的属性自不必说,像Date这样的属性也是可以创建范围索引的。

hugegraph中二级索引的逻辑存储结构如下:

index_label_id | field_values | element_ids
  • index_label_id: 索引标签的Id
  • field_values: 属性的值,可以是单个属性,也可以是多个属性拼接而成
  • element_ids: 顶点或边的Id

各种后端具体的“表结构”都按照该逻辑结构设计,在cassandra后端中,index_label_id作为primary key列,field_valueselement_ids都作为cluster key。其实相比于二级索引,范围索引表只是将field_valuesindex_label_id调换了一下顺序,这样做是因为在cassandra中,cluster key列是按顺序存储的,而primary key列是哈希存储的。

下面给出一个例子

先创建一个范围索引标签:

  1. // 创建索引类型:”personByAge”,可以按“age”属性的范围快速查询对应的“person”顶点
  2. schema.indexLabel(“personByAge”)
  3. .onV(“person”)
  4. .by(“age”)
  5. .range()
  6. .create();

再插入几个顶点:

  1. graph.addVertex(T.label, “person”, T.id, “p1”, “age”, 29);
  2. graph.addVertex(T.label, “person”, T.id, “p2”, “age”, 27);
  3. graph.addVertex(T.label, “person”, T.id, “p3”, “age”, 32);

然后再看存储结构:

index_label_id | field_values | element_ids
—————±————-±————
1 | 27 | Sp2
1 | 29 | Sp1
1 | 32 | Sp3

可以看到,field_values列的值是按顺序存储的,当用户的查询条件中为“age”>=27 && “age”<30时,可以定位到第一条到第三条记录之间(左闭右开);当查询条件为“age”<29时,可以定位到第一条记录。

全文索引

全文索引用于全文检索,或者说模糊检索,就是用于检索那些“不容易准确描述”的属性值的。比如:“地址”、“描述”等内容很长的属性,全文索引只能对文本类型的属性创建。

hugegraph会使用第三方的分词器将文本切分成多个短词,每个短词分别存储为一条索引记录。查询的时候也会将条件中的词语切分为多个短词,然后每个短词去索引表中查询,最后把每个短词的查询结果取并集就得到了最终的element_ids。全文索引相比于二级索引,只是多个一个分词和将结果合并的步骤,其表结构与二级索引完全相同,这里便不再赘述。

下面给出一个例子

先创建一个全文索引标签:

  1. // 创建索引类型:”personByAddress”,可以根据“address”属性的部分快速查询对应的“person”顶点
  2. schema.indexLabel(“personByAddress”)
  3. .onV(“person”)
  4. .by(“address”)
  5. .search()
  6. .ifNotExist()
  7. .create();

插入几个顶点:

  1. graph.addVertex(T.label, “person”, T.id, “p1”, “address”, “Beijing Haidian Xierqi”);
  2. graph.addVertex(T.label, “person”, T.id, “p2”, “address”, “Shanghai Xuhui”);
  3. graph.addVertex(T.label, “person”, T.id, “p3”, “address”, “Beijing Chaoyang”);

cassandra中的存储结构:

field_values | index_label_id | element_ids
————-±—————±————
shanghai | 1 | Sp2
xuhui | 1 | Sp2
chaoyang | 1 | Sp3
xierqi | 1 | Sp1
haidian | 1 | Sp1
beijing | 1 | Sp1
beijing | 1 | Sp3

可以看到,每个“person”的“address”值都被拆分为了多个部分,比如:“Beijing Haidian Xierqi”被拆分成了“beijing”、“haidian”和“xierqi”,每个单词的首字母还被转换成了小写,但是不影响查询,因为查询的时候也会做同样的转换。

当用户这样查询时:g.V().hasLabel("person").has("address", Text.contains("Beijing"));,分词器会把“Beijing”拆分(转换)为“beijing”,然后命中两条记录;当使用g.V().hasLabel("person").has("address", Text.contains("Beijing Shanghai"));查询时,分词器会把“Beijing Haidian”拆分为“beijing”和“shanghai”,其中“beijing”能命中两条记录“Sp1”和“Sp3”,“shanghai”能命中“Sp2”,然后取并集得到“Sp1”、“Sp2”和“Sp3”。

联合索引

上面提到的索引和查询都是独立工作的,就是说一个查询只会涉及到一个索引类型(IndexLabel),比如示例中:根据“name”=“xxx”查“person”时只会涉及到“personByName”这一个二级索引;根据“age”>xxx查“person”时只会涉及到“personByAge”这一个范围索引;即使是在传入多个条件的二级索引(组合索引)和通过分词产生多条件的全文索引中,也只会涉及到各自的单个索引,即“personByNameAndAge”和“personByAddress”。

相信看到这里,大家会有两个疑惑:

  • 单独创建多个单列索引和创建一个组合索引有什么区别?比如创建两个单列索引:“personByName”、“personByAge”,和创建一个组合索引:“personByNameAndAge”会对查询产生怎样的影响。
  • 似乎只有组合索引支持多个条件的查询,而且组合索引只提供了前缀匹配的能力,那岂不是要将所有的属性排列组合都创建为索引,才能支持任意属性的查询?

其实这两个问题是有关联的,可以合并为一个问题。单独创建多个单列索引和创建一个组合索引的区别在于:单独创建多个单列索引既支持单个属性的查询,也支持多个属性的组合查询,而组合索引只支持前缀属性的查询。

就以“personByName”、“personByAge”和“personByNameAndAge”为例,“personByName”、“personByAge”支持通过“name”查,支持通过“age”查,也支持通过“name”和“age”一起查;但是“personByNameAndAge”只支持通过“name”查和通过“name”和“age”一起查,不支持作为后缀的“age”查。

那多个单列索引是如何支持多个属性的组合查询的呢?很简单,依次查询然后将结果取交集,如果交集为空,则停止查询(这里所说的查询是指查询索引表),这种索引模式称为联合索引。

那又有人问了,既然组合索引能做的联合索引都能做,组合索引的意义何在呢?其实稍微一下就能知道:组合索引提供了前缀匹配的能力,在多属性查询时,一次查询就能命中,而联合查询则需要做最多N(属性个数)次查询,明显组合索引效率更高。所以,大家要根据具体的业务场景创建好索引,如果某些属性经常要组合在一起查询,最好把它创建为组合索引。

下面给出一个例子

先创建几个索引标签:

  1. // 创建组合索引:”personByNameAndAge”,可以根据“name”、“name”+“age”快速查询对应的“person”顶点
  2. schema.indexLabel(“personByNameAndAge”)
  3. .onV(“person”)
  4. .by(“name”, “age”)
  5. .secondary()
  6. .create();
  7. // 创建范围索引:”personByAge”,可以根据“age”属性的范围查询对应的“person”顶点
  8. schema.indexLabel(“personByAge”)
  9. .onV(“person”)
  10. .by(“age”)
  11. .range()
  12. .create();
  13. // 创建全文索引:”personByAddress”,可以根据“address”属性的部分快速查询对应的“person”顶点
  14. schema.indexLabel(“personByAddress”)
  15. .onV(“person”)
  16. .by(“address”)
  17. .search()
  18. .ifNotExist()
  19. .create();

插入几个顶点:

  1. graph.addVertex(T.label, “person”, T.id, “p1”, “name”, “marko”, “age”, 29, “address”, “Beijing Haidian Xierqi”);
  2. graph.addVertex(T.label, “person”, T.id, “p2”, “name”, “marko”, “age”, 27, “address”, “Shanghai Xuhui”);
  3. graph.addVertex(T.label, “person”, T.id, “p3”, “name”, “josh”, “age”, 32, “address”, “Beijing Chaoyang”);

存储结构就不看了,我们直接来做查询:

  1. // 通过“personByNameAndAge”做前缀匹配,命中“p1”和“p2”
  2. g.V().hasLabel(“person”).has(“name”, “marko”).toList();
  3. // 通过“personByNameAndAge”做前缀匹配(全匹配),命中“p1”
  4. g.V().hasLabel(“person”).has(“name”, “marko”).has(“age”, 29).toList();
  5. // 通过“personByNameAndAge”做前缀匹配(全匹配),命中“p1” ,从这里看出,查询时属性的顺序无影响
  6. g.V().hasLabel(“person”).has(“age”, 29).has(“name”, “marko”).toList();
  7. // 通过“personByAge”做范围查询,命中“p1”和“p2”
  8. g.V().hasLabel(“person”).has(“age”, P.between(27, 30)).toList();
  9. // 通过“personByAddress”做全文检索,命中“p1”和“p3”
  10. g.V().hasLabel(“person”).has(“address”, Text.contains(“Beijing”)).toList();
  11. // 通过“personByNameAndAge”和“personByAddress”做联合索引查询,命中“p1”
  12. g.V().hasLabel(“person”).has(“name”, “marko”).has(“address”, Text.contains(“Beijing”)).toList();
  13. // 通过“personByAge”和“personByAddress”做联合索引查询,命中“p1”
  14. g.V().hasLabel(“person”).has(“age”, P.between(27, 30)).has(“address”, Text.contains(“Beijing”)).toList();

 

 

授予每个自然月内发布4篇或4篇以上原创或翻译IT博文的用户。不积跬步无以至千里,不积小流无以成江海,程序人生的精彩需要坚持不懈地积累!

[转]golang 实现延迟消息原理与方法

实现延迟消息最主要的两个结构:

环形队列:通过golang中的数组实现,分成3600个slot。

任务集合:通过map[key]*Task,每个slot一个map,map的值就是我们要执行的任务。

原理图如下:

140030-20170726142613250-1793759044

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
package main;
import (
    "time"
    "errors"
    "fmt"
)
//延迟消息
type DelayMessage struct {
    //当前下标
    curIndex int;
    //环形槽
    slots [3600]map[string]*Task;
    //关闭
    closed chan bool;
    //任务关闭
    taskClose chan bool;
    //时间关闭
    timeClose chan bool;
    //启动时间
    startTime time.Time;
}
//执行的任务函数
type TaskFunc func(args ...interface{});
//任务
type Task struct {
    //循环次数
    cycleNum int;
    //执行的函数
    exec   TaskFunc;
    params []interface{};
}
//创建一个延迟消息
func NewDelayMessage() *DelayMessage {
    dm := &DelayMessage{
        curIndex:  0,
        closed:    make(chan bool),
        taskClose: make(chan bool),
        timeClose: make(chan bool),
        startTime: time.Now(),
    };
    for i := 0; i < 3600; i++ {
        dm.slots[i] = make(map[string]*Task);
    }
    return dm;
}
//启动延迟消息
func (dm *DelayMessage) Start() {
    go dm.taskLoop();
    go dm.timeLoop();
    select {
    case <-dm.closed:
        {
            dm.taskClose <- true;
            dm.timeClose <- true;
            break;
        }
    };
}
//关闭延迟消息
func (dm *DelayMessage) Close() {
    dm.closed <- true;
}
//处理每1秒的任务
func (dm *DelayMessage) taskLoop() {
    defer func() {
        fmt.Println("taskLoop exit");
    }();
    for {
        select {
        case <-dm.taskClose:
            {
                return;
            }
        default:
            {
                //取出当前的槽的任务
                tasks := dm.slots[dm.curIndex];
                if len(tasks) > 0 {
                    //遍历任务,判断任务循环次数等于0,则运行任务
                    //否则任务循环次数减1
                    for k, v := range tasks {
                        if v.cycleNum == 0 {
                            go v.exec(v.params...);
                            //删除运行过的任务
                            delete(tasks, k);
                        else {
                            v.cycleNum--;
                        }
                    }
                }
            }
        }
    }
}
//处理每1秒移动下标
func (dm *DelayMessage) timeLoop() {
    defer func() {
        fmt.Println("timeLoop exit");
    }();
    tick := time.NewTicker(time.Second);
    for {
        select {
        case <-dm.timeClose:
            {
                return;
            }
        case <-tick.C:
            {
                fmt.Println(time.Now().Format("2006-01-02 15:04:05"));
                //判断当前下标,如果等于3599则重置为0,否则加1
                if dm.curIndex == 3599 {
                    dm.curIndex = 0;
                else {
                    dm.curIndex++;
                }
            }
        }
    }
}
//添加任务
func (dm *DelayMessage) AddTask(t time.Time, key string, exec TaskFunc, params []interface{}) error {
    if dm.startTime.After(t) {
        return errors.New("时间错误");
    }
    //当前时间与指定时间相差秒数
    subSecond := t.Unix() - dm.startTime.Unix();
    //计算循环次数
    cycleNum := int(subSecond / 3600);
    //计算任务所在的slots的下标
    ix := subSecond % 3600;
    //把任务加入tasks中
    tasks := dm.slots[ix];
    if _, ok := tasks[key]; ok {
        return errors.New("该slots中已存在key为" + key + "的任务");
    }
    tasks[key] = &Task{
        cycleNum: cycleNum,
        exec:     exec,
        params:   params,
    };
    return nil;
}
func main() {
    //创建延迟消息
    dm := NewDelayMessage();
    //添加任务
    dm.AddTask(time.Now().Add(time.Second*10), "test1"func(args ...interface{}) {
        fmt.Println(args...);
    }, []interface{}{1, 2, 3});
    dm.AddTask(time.Now().Add(time.Second*10), "test2"func(args ...interface{}) {
        fmt.Println(args...);
    }, []interface{}{4, 5, 6});
    dm.AddTask(time.Now().Add(time.Second*20), "test3"func(args ...interface{}) {
        fmt.Println(args...);
    }, []interface{}{"hello""world""test"});
    dm.AddTask(time.Now().Add(time.Second*30), "test4"func(args ...interface{}) {
        sum := 0;
        for arg := range args {
            sum += arg;
        }
        fmt.Println("sum : ", sum);
    }, []interface{}{1, 2, 3});
    //40秒后关闭
    time.AfterFunc(time.Second*40, func() {
        dm.Close();
    });
    dm.Start();
}

测试结果如下:

[转]学完这100多技术,能当架构师么?

https://juejin.im/post/5d5375baf265da03b2152f3d

前几天,有个搞培训的朋友,和我要一份java后端的进阶路线图,我就把这篇文章发给了他《必看!java后端,亮剑诛仙》。今天,又想要个java后端目前最常用的工具和框架,正好我以前画过这样一张图,于是发给了他。虽然不是很全,但也希望得到他的夸奖。没想到…


本篇内容涵盖14个方面,涉及上百个框架和工具。会有你喜欢的,大概也会有你所讨厌的家伙。这是我平常工作中打交道最多的工具,大小公司都适用。如果你有更好的,欢迎留言补充。

一、消息队列
二、缓存
三、分库分表
四、数据同步
五、通讯
六、微服务
七、分布式工具
八、监控系统
九、调度
十、入口工具
十一、OLT(A)P
十二、CI/CD
十三、问题排查
十四、本地工具
复制代码

一、消息队列


一个大型的分布式系统,通常都会异步化,走消息总线。 消息队列作为最主要的基础组件,在整个体系架构中,有着及其重要的作用。kafka是目前最常用的消息队列,尤其是在大数据方面,有着极高的吞吐量。而rocketmq和rabbitmq,都是电信级别的消息队列,在业务上用的比较多。2019年了,不要再盯着JMS不放了(说的就是臃肿的ActiveMQ)。

pulsar是为了解决一些kafka上的问题而诞生的消息系统,比较年轻,工具链有限。有些激进的团队经过试用,反响不错。

mqtt具体来说是一种协议,主要用在物联网方面,能够双向通信,属于消息队列范畴。

二、缓存


数据缓存是减少数据库压力的有效途径,有单机java内缓存,和分布式缓存之分。

对于单机来说,guava的cache和ehcache都是些熟面孔。

对于分布式缓存来说,优先选择的就是redis,别犹豫。由于redis是单线程的,并不适合高耗时操作。所以对于一些数据量比较大的缓存,比如图片、视频等,使用老牌的memcached效果会好的多。

JetCache是一个基于Java的缓存系统封装,提供统一的api和注解来简化缓存的使用。类似SpringCache,支持本地缓存和分布式缓存,是简化开发的利器。

三、分库分表


分库分表,几乎每一个上点规模的公司,都会有自己的方案。目前,推荐使用驱动层的sharding-jdbc,或者代理层的mycat。如果你没有额外的运维团队,又不想花钱买其他机器,那么就选前者。

如果分库分表涉及的项目不多,spring的动态数据源是一个非常好的选择。它直接编码在代码里,直观但不易扩展。

如果只需要读写分离 ,那么mysql官方驱动里的replication协议,是更加轻量级的选择。

上面的分库分表组件,都是大浪淘沙,最终的优胜品。这些组件不同于其他组件选型,方案一旦确定,几乎无法回退,所以要慎之又慎。

分库分表是小case,准备分库分表的阶段,才是重点:也就是数据同步。

四、数据同步


国内使用mysql的公司居多,但postgresql凭借其优异的性能,使用率逐渐攀升。

不管什么数据库,实时数据同步工具,都是把自己模拟成一个从库,进行数据拉取和解析。 具体来说,mysql是通过binlog进行同步;postgresql使用wal日志进行同步。

对mysql来说,canal是国内用的最多的方案;类似的databus也是比较好用的工具。

现在,canal、maxwell等工具,都支持将要同步的数据写入到mq中,进行后续处理,方便了很多。

对于ETL(抽取、清洗、转换)来说,基本上都是source、task、sink路线,与前面的功能对应。gobblin、datax、logstash、sqoop等,都是这样的工具。

它们的主要工作,就是怎么方便的定义配置文件,编写各种各样的数据源适配接口等。这些ETL工具,也可以作为数据同步(尤其是全量同步)的工具,通常是根据ID,或者最后更新时间 等,进行处理。

binlog是实时增量工具,ETL工具做辅助。通常一个数据同步功能,需要多个组件的参与,他们共同组成一个整体。

五、通讯


Java 中,netty已经成为当之无愧的网络开发框架,包括其上的socketio(不要再和我提mina了)。对于http协议,有common-httpclient,以及更加轻量级的工具okhttp来支持。

对于一个rpc来说,要约定一个通讯方式和序列化方式。json是最常用的序列化方式,但是传输和解析成本大,xml等文本协议与其类似,都有很多冗余的信息;avro和kryo是二进制的序列化工具,没有这些缺点,但调试不便。

rpc是远程过程调用的意思 ,其中,thrift、dubbo、gRPC默认都是二进制序列化方式的socket通讯框架;feign、hessian都是onhttp的远程调用框架。

对了,gRPC的序列化工具是protobuf,一个压缩比很高的二进制序列化工具。

通常,服务的响应时间主要耗费在业务逻辑以及数据库上,通讯层耗时在其中的占比很小。可以根据自己公司的研发水平和业务规模来选择。

六、微服务


我们不止一次说到微服务,这一次我们从围绕它的一堆支持框架,来窥探一下这个体系。是的,这里依然是在说spring cloud。

默认的注册中心eureka不再维护,consul已经成为首选。nacos、zookeeper等,都可以作为备选方案。其中nacos带有后台,比较适合国人使用习惯。

熔断组件,官方的hystrix也已经不维护了。推荐使用resilience4j,最近阿里的sentinel也表现强劲。

对于调用链来说,由于OpenTracing的兴起,有了很多新的面孔。推荐使用jaeger或者skywalking。spring cloud集成的sleuth+zipkin功能稍弱,甚至不如传统侵入式的cat。

配置中心是管理多环境配置文件的利器,尤其在你不想重启服务器的情况下进行配置更新。目前,开源中做的最好的要数apollo,并提供了对spring boot的支持。disconf使用也较为广泛。相对来说,spring cloud config功能就局限了些,用的很少。


网关方面,使用最多的就是nginx,在nginx之上,有基于lua脚本的openrestry。由于openresty的使用非常繁杂,所以有了kong这种封装级别更高的网关。

对于spring cloud来说,zuul系列推荐使用zuul2,zuul1是多线程阻塞的,有硬伤。spring-cloud-gateway是spring cloud亲生的,但目前用的不是很广泛。

七、分布式工具


大家都知道分布式系统zookeeper能用在很多场景,与其类似的还有基于raft协议的etcd和consul。

由于它们能够保证极高的一致性,所以用作协调工具是再好不过了。用途集中在:配置中心、分布式锁、命名服务、分布式协调、master选举等场所。

对于分布式事务方面,则有阿里的fescar工具进行支持。但如非特别的必要,还是使用柔性事务,追寻最终一致性,比较好。

八、监控系统


监控系统组件种类繁多,目前,最流行的大概就是上面四类。

zabbix在主机数量不多的情况下,是非常好的选择。

prometheus来势凶猛,大有一统天下的架势。它也可以使用更加漂亮的grafana进行前端展示。

influxdata的influxdb和telegraf组件,都比较好用,主要是功能很全。

使用es存储的elkb工具链,也是一个较好的选择。我所知道的很多公司,都在用。

九、调度


大家可能都用过cron表达式。这个表达式,最初就是来自linux的crontab工具。

quartz是java中比较古老的调度方案,分布式调度采用数据库锁的方式,管理界面需要自行开发。

elastic-job-cloud应用比较广泛,但系统运维复杂,学习成本较高。相对来说,xxl-job就更加轻量级一些。中国人开发的系统,后台都比较漂亮。

十、入口工具


为了统一用户的访问路口,一般会使用一些入口工具进行支持。

其中,haproxy、lvs、keepalived等,使用非常广泛。

服务器一般采用稳定性较好的centos,并配备ansible工具进行支持,那叫一个爽。

十一、OLT(A)P


现在的企业,数据量都非常大,数据仓库是必须的。

搜索方面,solr和elasticsearch比较流行,它们都是基于lucene的。solr比较成熟,稳定性更好一些,但实时搜索方面不如es。

列式存储方面,基于Hadoop 的hbase,使用最是广泛;基于LSM的leveldb写入性能优越,但目前主要是作为嵌入式引擎使用多一些。

tidb是国产新贵,兼容mysql协议,公司通过培训向外输出dba,未来可期。

时序数据库方面,opentsdb用在超大型监控系统多一些。druid和kudu,在处理多维度数据实时聚合方面,更胜一筹。

cassandra在刚出现时火了一段时间,虽然有facebook弃用的新闻,但生态已经形成,常年霸占数据库引擎前15名。


十二、CI/CD


为了支持持续集成和虚拟化,除了耳熟能详的docker,我们还有其他工具。

jenkins是打包发布的首选,毕竟这么多年了,一直是老大哥。当然,写Idea的那家公司,还出了一个叫TeamCity的工具,操作界面非常流畅。

sonar(注意图上的错误)不得不说是一个神器,用了它之后,小伙伴们的代码一片飘红,我都快被吐沫星子给淹没了。

对于公司内部来说,一般使用gitlab搭建git服务器。其实,它里面的gitlab CI,也是非常好用的。

十三、问题排查


java经常发生内存溢出问题。使用jmap导出堆栈后,我一般使用mat进行深入分析。

如果在线上实时分析,有arthas和perf两款工具。

当然,有大批量的linux工具进行支持。比如下面这些:

《Linux上,最常用的一批命令解析(10年精选)》

十四、本地工具


本地使用的jar包和工具,那就多了去了。下面仅仅提一下最最常用的几个。

数据库连接池方面,国内使用druid最多。目前,有号称速度最快的hikari数据库连接池,以及老掉牙的dbcp和c3p0。

json方面,国内使用fastjson最多,三天两头冒出个漏洞;国外则使用jackson多一些。它们的api都类似,jackson特性多一些,但fastjson更加容易使用。

工具包方面,虽然有各种commons包,guava首选。

End

今天是2019年8月13日。台风利奇马刚刚肆虐完毕。

这种文章,每一年我都会整理一次。有些新面孔,也有些被我个人t出局。架构选型,除了你本身对某项技术比较熟悉,用起来更放心。更多的是需要进行大量调研、对比,直到掌握。

技术日新月异,新瓶装旧酒,名词一箩筐,程序员很辛苦。唯有那背后的基础原理,大道至简的思想,经久不衰。

作者:小姐姐味道
链接:https://juejin.im/post/5d5375baf265da03b2152f3d
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

Raft的理解

Feb 9, 2018

https://tinylcy.me/2018/Understanding-the-Raft-consensus-algorithm-One/

重新阅读了 Raft 论文,结合 John Ousterhout 在斯坦福大学的课程视频,对 Raft 重新梳理了一遍,并决定用文字记录下来。

Raft 是一个共识算法,何为共识算法?通俗的说,共识算法的目的就是要实现分布式环境下各个节点上数据达成一致。那么节点的数据为什么会出现不一致?原因有很多,例如节点宕机、网络延迟、数据包乱序等等。但是要注意的是,Raft 并不考虑存在恶意的节点的情况,也就是说,不存在主动篡改数据的节点。所以可以理解为:允许节点宕机,但是只要节点没有宕机,那么它就是正常工作的。

Slide 1

Raft 是为 Replicated Logs 设计的共识算法。一条日志对应于一个指令。可以这么理解:如果各个节点的日志在数量顺序都达成一致,那么节点只需顺序执行日志,就能够得到一致的结果。注意,真正执行日志的是状态机(State Machine),Raft 协调的正是日志和状态机。

Slide 2

再次回顾 Replicated Log,Raft 需要实现将日志完全一致的复制到其他节点,进而创建多副本状态机(Replicated State Machine),状态机可以理解为一个确定的应用程序,所谓确定是指只要是相同的输入,那么任何状态机都会计算出相同的输出。至于如何实现日志完全一致的复制,则是 Raft 即一致性模块(Consensus Module)需要做的事。

重新思考,为什么需要在多个节点维护一份完全一致的日志?如果只有 1 个节点提供服务,那么它就会成为整个系统的瓶颈,如果这个节点崩溃了,服务也就不能提供了。所以很自然的,需要让多个节点能够提供服务,也就是说,如果提供服务的某个节点崩溃了,系统中其他节点依旧可以提供等价的服务,但是如何做到等价?这就需要系统中的节点维持一致的状态。注意,实际上并不需要所有的节点同时拥有一致的状态,只要大多数节点拥有即可。大多数指的是:如果一共存在 3 个节点,允许 1 个节点不能正常工作;如果一共有 5 个节点,允许 2 个节点不能正常工作。为什么是大多数?我们将通过接下来的 Slides 进一步理解。

Slide 3

共识算法通常分为两类:对称式共识算法和非对称式共识算法。

  • 对称式共识算法指网络中不存在中心节点 Leader,所有的节点都具有相同的地位,节点与节点之间通过互相通信来达成共识,即网络拓扑结构类似 P2P 网络。可想而知,对称式类的共识协议会非常复杂,但是性能会更好,因为网络中的节点可以同时提供服务。
  • 非对称式共识算法会选举出一个 Leader,剩余的节点作为 Follower,客户端只能和 Leader 通信,节点之间的共识通过 Leader 来协调。相比于对称式共识算法,非对称式共识算法能够简化算法的设计,所有的操作都通过 Leader 完成,Follower 只需被动接受来自 Leader 的消息。

Raft 是一种非对称的共识算法,也正是采用了非对称的设计,Raft 得以将整个共识过程分解:共识算法正常运行和 Leader 变更

Slide 4

Raft 论文中多次强调 Raft 的设计是围绕算法的可理解性展开,我们将从六个部分对 Raft 进行理解。

  • Leader 选举,以及如何检测异常并进行新一轮的 Leader 选举。
  • 基本的日志复制操作,也就是 Raft 正常运行时的操作。
  • 在 Leader 发生变更时如何保证安全性和一致性,这是 Raft 算法最关键的部分。
  • 如何避免过时的 Leader 带来的影响,因为一个 Leader 宕机后再恢复仍然会认为自己是 Leader。
  • 客户端交互,所谓实现线性化语义可以理解为实现幂等性。
  • 配置变更,如何维持在线增删节点时的安全性和一致性。

Slide 5

Raft 算法有几个关键属性,我们需要提前了解。首先是节点的状态,相比于 Paxos,Raft 简化了节点可能的状态,在任何时候,节点可能处于以下三种状态。

  • Leader。Leader 负责处理客户端的请求,同时还需要协调日志的复制。在任意时刻,最多允许存在 1 个 Leader,也就是说,可能存在 0 个 Leader,什么时候会出现不存在 Leader 的情况?接下来会说明。
  • Follower。在 Raft 中,Follower 是一个完全被动的角色,Follower 只会响应消息。注意,在 Raft 中,节点之间的通信是通过 RPC 进行的。
  • Candidate。Candidate 是节点从 Follower 转变为 Leader 的过渡状态。因为 Follower 是一个完全被动的状态,所以当需要重新选举时,Follower 需要将自己提升为 Candidate,然后发起选举。

Raft 正常运行时只有一个 Leader,其余节点均为 Follower。

从状态转换图可以看到,所有的节点都是从 Follower 开始,如果 Follower 经过一段时间后收不到来自 Leader 的心跳,那么 Follower 就认为需要 Leader 已经崩溃了,需要进行新一轮的选举,因此 Follower 的状态变更为 Candidate。Candidate 有可能被选举为 Leader,也有可能回退为 Follower,具体情况下文会继续分析。如果 Leader 发现自己已经过时了,它会主动变更为 Follower,Leader 如何发现自己过时了?我们下文也会分析。

Slide 6

Raft 的另一个关键属性是任期(Term),在分布式系统中,由于节点的物理时间戳都不统一,因此需要一个逻辑时间戳来表明事件发生的先后顺序,Term 正是起到了逻辑时间戳的作用。Raft 的运行过程被划分为一系列 Term,一次 Leader 选举会开启一个新的 Term。

因为一次选举最多允许产生一个 Leader,一次选举又会开启一个新的 Term,所以每个 Leader 都会维护自己当前的 Term(Current Term)。注意,Leader 需要持久化存储 Current Term,当 Leader 宕机后再恢复,Leader 仍然会认为自己是 Leader,除非发现自己已经过时了,如何发现自己过时?依靠的正是 Current Term 的值。

一次 Term 也可能选不出 Leader,这是因为各个 Candidate 都获得了相同数量的选票,具体细节下文会再阐述。目前我们需要知道的是 Term 在 Raft 中是一个非常关键的属性,Term 始终保持单调递增,而 Raft 认为一个节点的 Term 越大,那么它所拥有的日志就越准确。

Slide 7

需要注意的是,Raft 有需要持久化存储的状态,包括 Current Term、VotedFor(下文会解析)和日志。每个日志项结构非常简单,包括日志所在 Term、Index 和状态机需要执行的指令。节点之间的 RPC 消息分为两类,一类为选举时的消息,另一类为 Raft 正常运行时的消息。具体细节我们会在下文理解。

Slide 8

Raft 中 Leader 和 Follower 之间需要通过心跳消息来维持关系,Follower 一旦在 Election Timeout 后没有收到来自 Leader 的心跳消息,那么 Follower 就认为 Leader 已经崩溃了,于是就发起一轮新的选举。在 Raft 中,心跳消息复用日志复制消息 AppendEntries 数据结构,只不过不携带任何日志。

Slide 9

现在开始正式理解 Raft 的选举过程,大部分内容已有所介绍,我们再梳理一遍。

当新的一轮选举开始时,Follower 首先要自增当前 Term,代表进入新的任期,紧接着变更状态为 Candidate,每个 Candidate 会先给自己投上一票,然后通过发送 RequestVote RPC 消息呼吁其他节点给自己投票。选举结果存在三种可能。

  • Candidate 收到了大多数节点的投票,那么 Candidate 自然就成为 Leader,然后马上发送心跳消息维护自己的 Leader 地位,并对外提供服务。
  • Candidate 在等待来自其他节点的选票的过程中收到了来自 Leader 的心跳消息,Candidate 可以看到当前的心跳消息中包含更新的 Term,就会意识到新的 Leader 已经被选举出来,于是就自降为 Follower。
  • 各个 Candidate 都获得了相同数量的选票,那么每个节点都会继续等待选票,没有新的 Leader 产生。等待一定的时间后,重新开启选举过程,直到选举出新的 Leader。

需要考虑的是 Raft 如何避免重复出现 Candidate 瓜分选票的情况:如果当前轮选举 Candidate 瓜分了选票,那么Candidate 会进入下一轮的选举,但是各个 Candidate 开始选举的时刻是随机的。

Slide 10

继续理解选举过程,选举过程需要保证两个特性:Safety 和 Liveness。

  • Safety 要求每个 Term 最多只能选举出一个 Leader,Raft 约束每个节点除了能给自己投一票,也给其他节点只能投一票。因此,如果 Candidate A 已经获得了大多数选票,由于每个节点只能向外投一票,因此 Candidate B 不可能获得大多数选票。Safety 特性保证一段时间内只可能存在一个 Leader 提供服务并协调日志的复制,避免因为存在多个 Leader 导致日志不一致。
  • Safety 保证在一段时间内最多只能存在一个 Leader,而 Liveness 保证系统最终必须要有要有一个 Candidate 赢得选举成为 Leader,Leader 无法选举出来意味着系统不能对外提供服务。Raft 实现 Liveness 的方式很简单,在 Slide 9 已经提及:当某一轮选举 Candidate 瓜分了选票,那么各个节点进入下一轮选举等待的时间是随机的,Candidate 随机等待 [T, 2T], T 为选举超时时间,这样就大大减少了再次瓜分选票的概率。

小结

对 Raft Leader 选举过程的理解基本结束,Raft 为了提高算法的可理解性,将问题分解,我们接下来会继续理解 Raft 的剩余部分。

[转]漫谈五种IO模型(主讲IO多路复用)

原文链接https://www.jianshu.com/p/6a6845464770

首先引用levin的回答让我们理清楚五种IO模型

1.阻塞I/O模型
老李去火车站买票,排队三天买到一张退票。
耗费:在车站吃喝拉撒睡 3天,其他事一件没干。

2.非阻塞I/O模型
老李去火车站买票,隔12小时去火车站问有没有退票,三天后买到一张票。耗费:往返车站6次,路上6小时,其他时间做了好多事。

3.I/O复用模型
1.select/poll
老李去火车站买票,委托黄牛,然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。
耗费:往返车站2次,路上2小时,黄牛手续费100元,打电话17次
2.epoll
老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。
耗费:往返车站2次,路上2小时,黄牛手续费100元,无需打电话

4.信号驱动I/O模型
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李,然后老李去火车站交钱领票。
耗费:往返车站2次,路上2小时,免黄牛费100元,无需打电话

5.异步I/O模型
老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。
耗费:往返车站1次,路上1小时,免黄牛费100元,无需打电话

1. I/O多路复用

1.1 它的形成原因

如果一个I/O流进来,我们就开启一个进程处理这个I/O流。那么假设现在有一百万个I/O流进来,那我们就需要开启一百万个进程一一对应处理这些I/O流(——这就是传统意义下的多进程并发处理)。思考一下,一百万个进程,你的CPU占有率会多高,这个实现方式及其的不合理。所以人们提出了I/O多路复用这个模型,一个线程,通过记录I/O流的状态来同时管理多个I/O,可以提高服务器的吞吐能力

1.2 通过它的英文单词来理解一下I/O多路复用

I/O multiplexing 也就是我们所说的I/O多路复用,但是这个翻译真的很不生动,所以我更喜欢将它拆开,变成 I/O multi plexing
multi意味着多,而plex意味着丛(丛:聚集,许多事物凑在一起。),那么字面上来看I/O multiplexing 就是将多个I/O凑在一起。就像下面这张图的前半部分一样,中间的那条线就是我们的单个线程,它通过记录传入的每一个I/O流的状态来同时管理多个IO。

multiplexing
1.3 I/O多路复用的实现
I/O多路复用模型

我们来分析一下上面这张图

  1. 当进程调用select,进程就会被阻塞
  2. 此时内核会监视所有select负责的的socket,当socket的数据准备好后,就立即返回。
  3. 进程再调用read操作,数据就会从内核拷贝到进程。

其实多路复用的实现有多种方式:select、poll、epoll

1.3.1 select实现方式

先理解一下select这个函数的形参都是什么

int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

  • nfds:指定待测试的描述子个数
  • readfds,writefds,exceptfds:指定了我们让内核测试读、写和异常条件的描述字
  • fd_set:为一个存放文件描述符的信息的结构体,可以通过下面的宏进行设置。

void FD_ZERO(fd_set *fdset);
//清空集合
void FD_SET(int fd, fd_set *fdset);
//将一个给定的文件描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset);
//将一个给定的文件描述符从集合中删除
int FD_ISSET(int fd, fd_set *fdset);
// 检查集合中指定的文件描述符是否可以读写

  • timeout:内核等待指定的描述字中就绪的时间长度
  • 返回值:失败-1 超时0 成功>0
#define FILE "/dev/input/mouse0"
int main(void)
{
 int fd = -1;
 int sele_ret = -1;
 fd_set Fd_set;
 struct timeval time = {0};
 char buf[10] = {0};

 //打开设备文件
 fd = open(FILE, O_RDONLY);
 if (-1 == fd)
{
      perror("open error");
      exit(-1);
}

//构建多路复用IO
FD_ZERO(&Fd_set); //清除全部fd
FD_SET(0, &Fd_set); //添加标准输入
FD_SET(fd, &Fd_set); //添加鼠标
time.tv_sec = 10; //设置阻塞超时时间为10秒钟
time.tv_usec = 0; 

sele_ret = select(fd+1, &Fd_set, NULL, NULL, &time);
if (0 > sele_ret)
{
    perror("select error");
    exit(-1);
}
else if (0 == sele_ret)
{
    printf("无数据输入,等待超时.\n");
}
else
{
    if (FD_ISSET(0, &Fd_set)) //监听得到得到的结果若是键盘,则让去读取键盘的数据
{
    memset(buf, 0, sizeof(buf));
    read(0, buf, sizeof(buf)/2);
    printf("读取键盘的内容是: %s.\n", buf);
}

if (FD_ISSET(fd, &Fd_set)) //监听得到得到的结果若是鼠标,则去读取鼠标的数据
{
    memset(buf, 0, sizeof(buf));
    read(fd, buf, sizeof(buf)/2);
    printf("读取鼠标的内容是: %s.\n", buf);
}
}

//关闭鼠标设备文件
    close(fd);
    return 0;
}
1.3.2 poll实现方式

先理解一下poll这个函数的形参是什么

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  • pollfd:又是一个结构体
struct pollfd {
int fd; //文件描述符
short events; //请求的事件(请求哪种操作)
short revents; //返回的事件
};

后两个参数都与select的第一和最后一个参数概念一样,就不细讲了

  • 返回值:失败-1 超时0 成功>0
#define FILE "/dev/input/mouse0"

int main(void)
{
    int fd = -1;
    int poll_ret = 0;
    struct pollfd poll_fd[2] = {0};
    char buf[100] = {0};

    //打开设备文件
    fd = open(FILE, O_RDONLY);
    if (-1 == fd)
    {
        perror("open error");
        exit(-1);
    }

    //构建多路复用IO
    poll_fd[0].fd = 0; //键盘
    poll_fd[0].events = POLLIN; //定义请求的事件为读数据
    poll_fd[1].fd = fd; //鼠标
    poll_fd[1].events = POLLIN; //定义请求的事件为读数据
    int time = 10000; //定义超时时间为10秒钟

    poll_ret = poll(poll_fd, fd+1, time);
    if (0 > poll_ret)
    {
        perror("poll error");
        exit(-1);
    }
     else if (0 == poll_ret)
    {
        printf("阻塞超时.\n");
    }
    else
    {
        if (poll_fd[0].revents == poll_fd[0].events)
 //监听得到得到的结果若是键盘,则让去读取键盘的数据
        {
            memset(buf, 0, sizeof(buf));
            read(0, buf, sizeof(buf)/2);
            printf("读取键盘的内容是: %s.\n", buf);
        }

        if (poll_fd[1].revents == poll_fd[1].events) 
//监听得到得到的结果若是鼠标,则去读取鼠标的数据
        {
              memset(buf, 0, sizeof(buf));
              read(fd, buf, sizeof(buf)/2);
              printf("读取鼠标的内容是: %s.\n", buf);
        }
  }
//关闭文件
close(fd);
return 0;
}
1.3.3 epoll实现方式(太过复杂,为了不增加篇幅不放进来了)

epoll操作过程中会用到的重要函数

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • int epoll_create(int size):创建一个epoll的句柄,size表示监听数目的大小。创建完句柄它会自动占用一个fd值,使用完epoll一定要记得close,不然fd会被消耗完。
  • int epoll_ctl:这是epoll的事件注册函数,和select不同的是select在监听的时候会告诉内核监听什么样的事件,而epoll必须在epoll_ctl先注册要监听的事件类型。
    它的第一个参数返回epoll_creat的执行结果
    第二个参数表示动作,用下面几个宏表示

EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;

第三参数为监听的fd,第四个参数是告诉内核要监听什么事

  • int epoll_wait:等待事件的发生,类似于select的调用

2. select

2.1 select函数的调用过程

a. 从用户空间将fd_set拷贝到内核空间
b. 注册回调函数
c. 调用其对应的poll方法
d. poll方法会返回一个描述读写是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
e. 如果遍历完所有的fd都没有返回一个可读写的mask掩码,就会让select的进程进入休眠模式,直到发现可读写的资源后,重新唤醒等待队列上休眠的进程。如果在规定时间内都没有唤醒休眠进程,那么进程会被唤醒重新获得CPU,再去遍历一次fd。
f. 将fd_set从内核空间拷贝到用户空间

2.2 select函数优缺点

缺点:两次拷贝耗时、轮询所有fd耗时,支持的文件描述符太小
优点:跨平台支持


3. poll

3.1 poll函数的调用过程(与select完全一致)
3.2 poll函数优缺点

优点:连接数(也就是文件描述符)没有限制(链表存储)
缺点:大量拷贝,水平触发(当报告了fd没有被处理,会重复报告,很耗性能)


4. epoll

4.1 epoll的ET与LT模式

LT延迟处理,当检测到描述符事件通知应用程序,应用程序不立即处理该事件。那么下次会再次通知应用程序此事件。
ET立即处理,当检测到描述符事件通知应用程序,应用程序会立即处理。

ET模式减少了epoll被重复触发的次数,效率比LT高。我们在使用ET的时候,必须采用非阻塞套接口,避免某文件句柄在阻塞读或阻塞写的时候将其他文件描述符的任务饿死

4.2 epoll的函数调用流程

a. 当调用epoll_wait函数的时候,系统会创建一个epoll对象,每个对象有一个evenpoll类型的结构体与之对应,结构体成员结构如下。

rbn,代表将要通过epoll_ctl向epll对象中添加的事件。这些事情都是挂载在红黑树中。
rdlist,里面存放的是将要发生的事件

b. 文件的fd状态发生改变,就会触发fd上的回调函数
c. 回调函数将相应的fd加入到rdlist,导致rdlist不空,进程被唤醒,epoll_wait继续执行。
d. 有一个事件转移函数——ep_events_transfer,它会将rdlist的数据拷贝到txlist上,并将rdlist的数据清空。
e. ep_send_events函数,它扫描txlist的每个数据,调用关联fd对应的poll方法去取fd中较新的事件,将取得的事件和对应的fd发送到用户空间。如果fd是LT模式的话,会被txlist的该数据重新放回rdlist,等待下一次继续触发调用。

4.3 epoll的优点
  1. 没有最大并发连接的限制
  2. 只有活跃可用的fd才会调用callback函数
  3. 内存拷贝是利用mmap()文件映射内存的方式加速与内核空间的消息传递,减少复制开销。(内核与用户空间共享一块内存)

只有存在大量的空闲连接和不活跃的连接的时候,使用epoll的效率才会比select/poll高


下面引用知乎一书焚城的回答再次巩固一下IO模型

  1. 阻塞IO, 给女神发一条短信, 说我来找你了, 然后就默默的一直等着女神下楼, 这个期间除了等待你不会做其他事情, 属于备胎做法.
  1. 非阻塞IO, 给女神发短信, 如果不回, 接着再发, 一直发到女神下楼, 这个期间你除了发短信等待不会做其他事情, 属于专一做法.
  1. IO多路复用, 是找一个宿管大妈来帮你监视下楼的女生, 这个期间你可以些其他的事情. 例如可以顺便看看其他妹子,玩玩王者荣耀, 上个厕所等等. IO复用又包括 select, poll, epoll 模式. 那么它们的区别是什么?
    3.1 select大妈 每一个女生下楼, select大妈都不知道这个是不是你的女神, 她需要一个一个询问, 并且select大妈能力还有限, 最多一次帮你监视1024个妹子
    3.2 poll大妈不限制盯着女生的数量, 只要是经过宿舍楼门口的女生, 都会帮你去问是不是你女神
    3.3 epoll大妈不限制盯着女生的数量, 并且也不需要一个一个去问. 那么如何做呢? epoll大妈会为每个进宿舍楼的女生脸上贴上一个大字条,上面写上女生自己的名字, 只要女生下楼了, epoll大妈就知道这个是不是你女神了, 然后大妈再通知你.

上面这些同步IO有一个共同点就是, 当女神走出宿舍门口的时候, 你已经站在宿舍门口等着女神的, 此时你属于阻塞状态

接下来是异步IO的情况
你告诉女神我来了, 然后你就去王者荣耀了, 一直到女神下楼了, 发现找不见你了, 女神再给你打电话通知你, 说我下楼了, 你在哪呢? 这时候你才来到宿舍门口. 此时属于逆袭做法

作者:凉拌姨妈好吃
链接:https://www.jianshu.com/p/6a6845464770
来源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

python词频统计,生成词云图片

之前看博客上总有人抓某个网站,然后做一张炫酷的词频统计图。虽然知道有现成的库,但是一直没机会实践。这次刚好试验一下,比较简单。


#-*- encoding:utf-8 -*-
import matplotlib.pyplot as plt
import jieba
from wordcloud import WordCloud
import collections

#1.读出歌词
text = open('./words.txt','r').read()
#2.把歌词剪开
cut_text = jieba.cut(text)
# print(type(cut_text))
# print(next(cut_text))
# print(next(cut_text))
#3.以空格拼接起来
remove_words = [u'的', u',',u'和', u'是', u'随着', u'对于', u'对',u'等',u'能', \
    u'都',u'。',u' ',u'、',u'中',u'在',u'了',u'通常',u'如果',u'我',u'需要',u'自己',\
    u'你',u'人',u'不',u'就',u'有',u'一个',u'也',u'而是',u'只是',u'可以',u'不要', \
    u'还是',u'不能',u'所有',u'那些',u'不会',u'那么',u'因为',u'只有',u'那些',u'也']
filter_words = [u'的', u'是',u'那', u'不']
object_list = []
#过滤词
for word in cut_text :
    if len(word) <= 1:
        continue

    flag = True
    if word not in remove_words: # 如果不在去除词库中
        for tmp_filter in filter_words :
            if word.find(tmp_filter) != -1 :
                flag = False
                break;
        if flag:
            object_list.append(word) 

word_counts = collections.Counter(object_list) # 对分词做词频统计
word_counts_top = word_counts.most_common(50) # 获取前10最高频的词
print word_counts_top

# print(result)
# 4.生成词云
wc = WordCloud(
    font_path='./YC.ttf',     #字体路径
    background_color='white',   #背景颜色
    width=1000,
    height=600,
    max_font_size=50,            #字体大小
    min_font_size=10,
    #mask=plt.imread('xin.jpg'),  #背景图片
    max_words=50
)
wc.generate_from_frequencies(word_counts)
wc.to_file('fin.png')    #图片保存

#5.显示图片
plt.figure('jielun')   #图片显示的名字
plt.imshow(wc)
plt.axis('off')        #关闭坐标
plt.show()

这里可以设置背景图片和字体格式。对于中文,一定要有ttf字体文件。我随便从github上找了一种字体。
亲测可用!

 

 

[转]Confluence环境搭建

原文地址:https://blog.csdn.net/u013952133/article/details/81634978

1. 环境准备

JDK1.8安装: Centos7下安装与卸载Jdk1.8

Mysql 5.7安装:CentOS7下YUM安装与配置MySQL5.7

  1. yum install -y mysql-server mysql mysql-devel
  2. service mysqld start #初始化及相关配置
  3. chkconfig –list | grep mysqld #查看mysql服务是不是开机自动启动
  4. chkconfig mysqld on #设置成开机启动
  5. mysqladmin -u root password ‘123456’  #通过该命令给root账号设置密码为 123456
  6. mysql -u root -p #登录mysql数据库
  7. create database confluence character set UTF8;
  8. grant all on confluence.* to confluence@“%” identified by “confluence”;
  9. grant all on confluence.* to confluence@“localhost” identified by “confluence”;
  10. FLUSH PRIVILEGES;
  11. quit #退出
  12. service mysqld stop #关闭mysql服务
  13. cd /etc/
  14. vi my.cnf
  15. 在[mysqld]下面加上character-set-server =utf8 #解决中文显示???的乱码问题
  16. service mysqld start #启动mysql服务

confluence安装

1)        下载atlassian-confluence-6.7.1-x64.bin安装包,

2)        修改文件权限chmod +xatlassian-confluence-6.3.1-x64.bin

3)        安装文件./atlassian-confluence-6.3.1-x64.bin

 2. 破解confluence

2.1        下载注册机

下载confluence_keygen.jar注册机,见附件

链接:https://pan.baidu.com/s/1gg85p4Z 密码:3t5b

2.2        破解jar包

将/opt/atlassian/confluence/confluence/WEB-INF/lib/atlassian-extras-decoder-v2-3.3.0.jarjar文件ftp到本地,并重命名为atlassian-extras-2.4.jar,运行confluence_keygen.jar,点击.patch,选择atlassian-extras-2.4.jar文件,点击打开,jar文件破解成功。

2.3        上传破解jar包

将破解后的atlassian-extras-2.4.jar上传到服务器/opt/atlassian/confluence/confluence/ WEB-INF/lib/目录下,并重命名为atlassian-extras-decoder-v2-3.3.0.jar

3         配置confluence

3.1        重启confluence服务

停止:sh /opt/atlassian/confluence/bin/stop-confluence.sh

启动:sh /opt/atlassian/confluence/bin/start-confluence.sh

3.2        访问confluence

登录http://192.168.137.121:8090/

3.3        选择中文界面

3.4        填写授权码

运行confluence_keygen.jar,随便填写NAME,输入Server ID,点击.gen,复制key到文本框中,点击下一步。

至此,confluence破解完成

3.5        配置其他操作

选择内置数据库

以上参考:

confluence6.7.1安装与破解

手把手教你实现Confluence6.7.1安装与破解

linux 破解版confluence安装

4. 遇到的问题以及解决方式

4.1 配置mysql的时候报如下错误:

 

解决方式:

打开mysql,设置@@global.tx_isolation, @@tx_isolation为READ-COMMITED。

4.2 打开报如下错误

解决方式:

先检查@@global.tx_isolation, @@tx_isolation为READ-COMMITED,用set方式设置貌似重启mysql后又会恢复为默认值REPEATABLE-READ,可参考官网指示:

https://confluence.atlassian.com/confkb/confluence-fails-to-start-and-throws-mysql-session-isolation-level-repeatable-read-is-no-longer-supported-error-241568536.html

修改mysql的my.cnf文件,在[mysqld]下添加:

我的修改后还是不行,原因是我在搭建起confluence后,将mysql远程访问给关闭了,还是一直报这个错误,后面讲mysql的远程访问给开启就好了。

4.3 启动confluence的时候报如下错:

原因:我的confluence目录权限是confluence,修改为root后就好了。

4.4 confluence启动过程中报错,提示无法加载confluence.cfg.xml,具体错误没有截图。

可能是由于confluence不支持openJDK,将openJDK卸载,安装JDK即可解决。

4.5 confluence服务器搭建成功后,新建page很慢,提示连接不上服务器

解决方式:在confluence界面上找到一般配置,将协同编辑关闭。

4.6 上传附件后预览显示乱码

将window上的中文相关字体安装到Linux服务器上,安装后即可正常显示。

4.7 confluence卸载

直接在/opt/atlassian/confluence目录下执行uninstall即可卸载。

[转]Long类型转json时前端js丢失精度解决方案

一、问题背景

Java后端开发过程中,尤其是id字段,因数值太大,通过json形式传输到前端后,在js解析时,会丢失精度。

如果对精度丢失没有什么概念,可以看一个知乎的帖子,来感受一下:https://www.zhihu.com/question/34564427?sort=created

二、解决思路

将id字段序列化为json时,转换为字符串类型,前端传输到后端,反序列化时,再重新转换为Long。

三、具体实现

在dto所在项目中,新建一个helper包(名字自定义,也可以放现有包里)。PS:为什么要建到dto项目中?因为,这个包最后可能会给其他组使用,这样以来,所有的处理规则逻辑都是统一的,方便对接。

在包里添加类LongJsonSerializer,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * Long 类型字段序列化时转为字符串,避免js丢失精度
 *
 */
public class LongJsonSerializer extends JsonSerializer<Long> {
    @Override
    public void serialize(Long value, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException, JsonProcessingException {
        String text = (value == null null : String.valueOf(value));
        if (text != null) {
            jsonGenerator.writeString(text);
        }
    }
}

然后在包里再添加类LongJsonDeserializer,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 将字符串转为Long
 *
 */
public class LongJsonDeserializer extends JsonDeserializer<Long> {
    private static final Logger logger = LoggerFactory.getLogger(LongJsonDeserializer.class);
 
    @Override
    public Long deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
        String value = jsonParser.getText();
        try {
            return value == null null : Long.parseLong(value);
        catch (NumberFormatException e) {
            logger.error("解析长整形错误", e);
            return null;
        }
    }
}

 

好了,接下来是使用这两个类。

在需要处理的id字段上,加上注解。比如如下代码:

1
2
3
4
5
6
/**
 * id
 */
@JsonSerialize(using = LongJsonSerializer.class)
@JsonDeserialize(using = LongJsonDeserializer.class)
private Long id;

[转]PHP7下的协程实现

转:https://segmentfault.com/a/1190000012457145?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io

相信大家都听说过『协程』这个概念吧。

但是有些同学对这个概念似懂非懂,不知道怎么实现,怎么用,用在哪,甚至有些人认为yield就是协程!

我始终相信,如果你无法准确地表达出一个知识点的话,我可以认为你就是不懂。

如果你之前了解过利用PHP实现协程的话,你肯定看过鸟哥的那篇文章:在PHP中使用协程实现多任务调度| 风雪之隅

鸟哥这篇文章是从国外的作者翻译来的,翻译的简洁明了,也给出了具体的例子了。

我写这篇文章的目的,是想对鸟哥文章做更加充足的补充,毕竟有部分同学的基础还是不够好,看得也是云头雾里的。

我个人,不喜欢写长篇文章,微博关注我 @码云 ,每天用微博分享知识。文章同时记录在我的博客:https://bruceit.com/p/A4kSfE

什么是协程

先搞清楚,什么是协程。

你可能已经听过『进程』和『线程』这两个概念。

进程就是二进制可执行文件在计算机内存里的一个运行实例,就好比你的.exe文件是个类,进程就是new出来的那个实例。

进程是计算机系统进行资源分配和调度的基本单位(调度单位这里别纠结线程进程的),每个CPU下同一时刻只能处理一个进程。

所谓的并行,只不过是看起来并行,CPU事实上在用很快的速度切换不同的进程。

进程的切换需要进行系统调用,CPU要保存当前进程的各个信息,同时还会使CPUCache被废掉。

所以进程切换不到非不得已就不做。

那么怎么实现『进程切换不到非不得已就不做』呢?

首先进程被切换的条件是:进程执行完毕、分配给进程的CPU时间片结束,系统发生中断需要处理,或者进程等待必要的资源(进程阻塞)等。你想下,前面几种情况自然没有什么话可说,但是如果是在阻塞等待,是不是就浪费了。

其实阻塞的话我们的程序还有其他可执行的地方可以执行,不一定要傻傻的等!

所以就有了线程。

线程简单理解就是一个『微进程』,专门跑一个函数(逻辑流)。

所以我们就可以在编写程序的过程中将可以同时运行的函数用线程来体现了。

线程有两种类型,一种是由内核来管理和调度。

我们说,只要涉及需要内核参与管理调度的,代价都是很大的。这种线程其实也就解决了当一个进程中,某个正在执行的线程遇到阻塞,我们可以调度另外一个可运行的线程来跑,但是还是在同一个进程里,所以没有了进程切换。

还有另外一种线程,他的调度是由程序员自己写程序来管理的,对内核来说不可见。这种线程叫做『用户空间线程』。

协程可以理解就是一种用户空间线程。

协程,有几个特点:

  • 协同,因为是由程序员自己写的调度策略,其通过协作而不是抢占来进行切换
  • 在用户态完成创建,切换和销毁
  • ⚠️ 从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
  • generator经常用来实现协程

说到这里,你应该明白协程的基本概念了吧?

PHP实现协程

一步一步来,从解释概念说起!

可迭代对象

PHP5提供了一种定义对象的方法使其可以通过单元列表来遍历,例如用foreach语句。

你如果要实现一个可迭代对象,你就要实现Iterator接口:

<?php
class MyIterator implements Iterator
{
    private $var = array();

    public function __construct($array)
    {
        if (is_array($array)) {
            $this->var = $array;
        }
    }

    public function rewind() {
        echo "rewinding\n";
        reset($this->var);
    }

    public function current() {
        $var = current($this->var);
        echo "current: $var\n";
        return $var;
    }

    public function key() {
        $var = key($this->var);
        echo "key: $var\n";
        return $var;
    }

    public function next() {
        $var = next($this->var);
        echo "next: $var\n";
        return $var;
    }

    public function valid() {
        $var = $this->current() !== false;
        echo "valid: {$var}\n";
        return $var;
    }
}

$values = array(1,2,3);
$it = new MyIterator($values);

foreach ($it as $a => $b) {
    print "$a: $b\n";
}

生成器

可以说之前为了拥有一个能够被foreach遍历的对象,你不得不去实现一堆的方法,yield关键字就是为了简化这个过程。

生成器提供了一种更容易的方法来实现简单的对象迭代,相比较定义类实现Iterator接口的方式,性能开销和复杂性大大降低。

<?php
function xrange($start, $end, $step = 1) {
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}
 
foreach (xrange(1, 1000000) as $num) {
    echo $num, "\n";
}

记住,一个函数中如果用了yield,他就是一个生成器,直接调用他是没有用的,不能等同于一个函数那样去执行!

所以,yield就是yield,下次谁再说yield是协程,我肯定把你xxxx。

PHP协程

前面介绍协程的时候说了,协程需要程序员自己去编写调度机制,下面我们来看这个机制怎么写。

0)生成器正确使用

既然生成器不能像函数一样直接调用,那么怎么才能调用呢?

方法如下:

  1. foreach他
  2. send($value)
  3. current / next…

1)Task实现

Task就是一个任务的抽象,刚刚我们说了协程就是用户空间协程,线程可以理解就是跑一个函数。

所以Task的构造函数中就是接收一个闭包函数,我们命名为coroutine

/**
 * Task任务类
 */
class Task
{
    protected $taskId;
    protected $coroutine;
    protected $beforeFirstYield = true;
    protected $sendValue;

    /**
     * Task constructor.
     * @param $taskId
     * @param Generator $coroutine
     */
    public function __construct($taskId, Generator $coroutine)
    {
        $this->taskId = $taskId;
        $this->coroutine = $coroutine;
    }

    /**
     * 获取当前的Task的ID
     * 
     * @return mixed
     */
    public function getTaskId()
    {
        return $this->taskId;
    }

    /**
     * 判断Task执行完毕了没有
     * 
     * @return bool
     */
    public function isFinished()
    {
        return !$this->coroutine->valid();
    }

    /**
     * 设置下次要传给协程的值,比如 $id = (yield $xxxx),这个值就给了$id了
     * 
     * @param $value
     */
    public function setSendValue($value)
    {
        $this->sendValue = $value;
    }

    /**
     * 运行任务
     * 
     * @return mixed
     */
    public function run()
    {
        // 这里要注意,生成器的开始会reset,所以第一个值要用current获取
        if ($this->beforeFirstYield) {
            $this->beforeFirstYield = false;
            return $this->coroutine->current();
        } else {
            // 我们说过了,用send去调用一个生成器
            $retval = $this->coroutine->send($this->sendValue);
            $this->sendValue = null;
            return $retval;
        }
    }
}

2)Scheduler实现

接下来就是Scheduler这个重点核心部分,他扮演着调度员的角色。

/**
 * Class Scheduler
 */
Class Scheduler
{
    /**
     * @var SplQueue
     */
    protected $taskQueue;
    /**
     * @var int
     */
    protected $tid = 0;

    /**
     * Scheduler constructor.
     */
    public function __construct()
    {
        /* 原理就是维护了一个队列,
         * 前面说过,从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制
         * */
        $this->taskQueue = new SplQueue();
    }

    /**
     * 增加一个任务
     *
     * @param Generator $task
     * @return int
     */
    public function addTask(Generator $task)
    {
        $tid = $this->tid;
        $task = new Task($tid, $task);
        $this->taskQueue->enqueue($task);
        $this->tid++;
        return $tid;
    }

    /**
     * 把任务进入队列
     *
     * @param Task $task
     */
    public function schedule(Task $task)
    {
        $this->taskQueue->enqueue($task);
    }

    /**
     * 运行调度器
     */
    public function run()
    {
        while (!$this->taskQueue->isEmpty()) {
            // 任务出队
            $task = $this->taskQueue->dequeue();
            $res = $task->run(); // 运行任务直到 yield

            if (!$task->isFinished()) {
                $this->schedule($task); // 任务如果还没完全执行完毕,入队等下次执行
            }
        }
    }
}

这样我们基本就实现了一个协程调度器。

你可以使用下面的代码来测试:

<?php
function task1() {
    for ($i = 1; $i <= 10; ++$i) {
        echo "This is task 1 iteration $i.\n";
        yield; // 主动让出CPU的执行权
    }
}
 
function task2() {
    for ($i = 1; $i <= 5; ++$i) {
        echo "This is task 2 iteration $i.\n";
        yield; // 主动让出CPU的执行权
    }
}
 
$scheduler = new Scheduler; // 实例化一个调度器
$scheduler->addTask(task1()); // 添加不同的闭包函数作为任务
$scheduler->addTask(task2());
$scheduler->run();

关键说下在哪里能用得到PHP协程。

function task1() {
        /* 这里有一个远程任务,需要耗时10s,可能是一个远程机器抓取分析远程网址的任务,我们只要提交最后去远程机器拿结果就行了 */
        remote_task_commit();
        // 这时候请求发出后,我们不要在这里等,主动让出CPU的执行权给task2运行,他不依赖这个结果
        yield;
        yield (remote_task_receive());
        ...
}
 
function task2() {
    for ($i = 1; $i <= 5; ++$i) {
        echo "This is task 2 iteration $i.\n";
        yield; // 主动让出CPU的执行权
    }
}

这样就提高了程序的执行效率。

关于『系统调用』的实现,鸟哥已经讲得很明白,我这里不再说明。

3)协程堆栈

鸟哥文中还有一个协程堆栈的例子。

我们上面说过了,如果在函数中使用了yield,就不能当做函数使用。

所以你在一个协程函数中嵌套另外一个协程函数:

<?php
function echoTimes($msg, $max) {
    for ($i = 1; $i <= $max; ++$i) {
        echo "$msg iteration $i\n";
        yield;
    }
}
 
function task() {
    echoTimes('foo', 10); // print foo ten times
    echo "---\n";
    echoTimes('bar', 5); // print bar five times
    yield; // force it to be a coroutine
}
 
$scheduler = new Scheduler;
$scheduler->addTask(task());
$scheduler->run();

这里的echoTimes是执行不了的!所以就需要协程堆栈。

不过没关系,我们改一改我们刚刚的代码。

把Task中的初始化方法改下,因为我们在运行一个Task的时候,我们要分析出他包含了哪些子协程,然后将子协程用一个堆栈保存。(C语言学的好的同学自然能理解这里,不理解的同学我建议去了解下进程的内存模型是怎么处理函数调用)

 /**
     * Task constructor.
     * @param $taskId
     * @param Generator $coroutine
     */
    public function __construct($taskId, Generator $coroutine)
    {
        $this->taskId = $taskId;
        // $this->coroutine = $coroutine;
        // 换成这个,实际Task->run的就是stackedCoroutine这个函数,不是$coroutine保存的闭包函数了
        $this->coroutine = stackedCoroutine($coroutine); 
    }

当Task->run()的时候,一个循环来分析:

/**
 * @param Generator $gen
 */
function stackedCoroutine(Generator $gen)
{
    $stack = new SplStack;

    // 不断遍历这个传进来的生成器
    for (; ;) {
        // $gen可以理解为指向当前运行的协程闭包函数(生成器)
        $value = $gen->current(); // 获取中断点,也就是yield出来的值

        if ($value instanceof Generator) {
            // 如果是也是一个生成器,这就是子协程了,把当前运行的协程入栈保存
            $stack->push($gen);
            $gen = $value; // 把子协程函数给gen,继续执行,注意接下来就是执行子协程的流程了
            continue;
        }

        // 我们对子协程返回的结果做了封装,下面讲
        $isReturnValue = $value instanceof CoroutineReturnValue; // 子协程返回`$value`需要主协程帮忙处理
        
        if (!$gen->valid() || $isReturnValue) {
            if ($stack->isEmpty()) {
                return;
            }
            // 如果是gen已经执行完毕,或者遇到子协程需要返回值给主协程去处理
            $gen = $stack->pop(); //出栈,得到之前入栈保存的主协程
            $gen->send($isReturnValue ? $value->getValue() : NULL); // 调用主协程处理子协程的输出值
            continue;
        }

        $gen->send(yield $gen->key() => $value); // 继续执行子协程
    }
}

然后我们增加echoTime的结束标示:

class CoroutineReturnValue {
    protected $value;
 
    public function __construct($value) {
        $this->value = $value;
    }
     
    // 获取能把子协程的输出值给主协程,作为主协程的send参数
    public function getValue() {
        return $this->value;
    }
}

function retval($value) {
    return new CoroutineReturnValue($value);
}

然后修改echoTimes

function echoTimes($msg, $max) {
    for ($i = 1; $i <= $max; ++$i) {
        echo "$msg iteration $i\n";
        yield;
    }
    yield retval("");  // 增加这个作为结束标示
}

Task变为:

function task1()
{
    yield echoTimes('bar', 5);
}

这样就实现了一个协程堆栈,现在你可以举一反三了。

4)PHP7中yield from关键字

PHP7中增加了yield from,所以我们不需要自己实现携程堆栈,真是太好了。

把Task的构造函数改回去:

    public function __construct($taskId, Generator $coroutine)
    {
        $this->taskId = $taskId;
        $this->coroutine = $coroutine;
        // $this->coroutine = stackedCoroutine($coroutine); //不需要自己实现了,改回之前的
    }

echoTimes函数:

function echoTimes($msg, $max) {
    for ($i = 1; $i <= $max; ++$i) {
        echo "$msg iteration $i\n";
        yield;
    }
}

task1生成器:

function task1()
{
    yield from echoTimes('bar', 5);
}

这样,轻松调用子协程