Go 中一个非典型不加锁读写变量案例分析

前段时间在 v2 看到一个关于并发读写变量的问题:go 一个线程写, 另外一个线程读, 为什么不能保证最终一致性。帖子中给出的例子非常简单(稍作修改)main.go

package main

import (
    "fmt"
    "runtime"
    "time"
)

var i = 0

func main() {
    runtime.GOMAXPROCS(2)
    go func() {
        for {
            fmt.Println("i am here", i)
            time.Sleep(time.Second)
        }
    }()
    for {
        i += 1
    }
}

既然是问题贴,直接运行的结果应该是出乎大多数人预料的:

╰─➤  go run main.go                                                                                                                                     1 ↵
i am here 0
i am here 0
i am here 0
i am here 0
i am here 0
i am here 0
...

帖子的回复比较多,涉及的信息量相对杂乱,爬完楼反而感觉没有看懂。这里就不卖关子,直接给出脱水后的结论:出现上面结果的原因是 go 的编译器把代码 i 自加 1 的 for 循环优化掉了。要验证这一点也很简单,我们使用 go tool objdump -s 'main\.main' main 查看编译出的二进制可执行文件的汇编代码:

╰─➤  go tool objdump -s 'main\.main' main
TEXT main.main(SB) /Users/liudanking/code/golang/gopath/src/test/main.go
  main.go:11        0x108de60       65488b0c25a0080000  MOVQ GS:0x8a0, CX
  main.go:11        0x108de69       483b6110        CMPQ 0x10(CX), SP
  main.go:11        0x108de6d       7635            JBE 0x108dea4
  main.go:11        0x108de6f       4883ec18        SUBQ $0x18, SP
  main.go:11        0x108de73       48896c2410      MOVQ BP, 0x10(SP)
  main.go:11        0x108de78       488d6c2410      LEAQ 0x10(SP), BP
  main.go:12        0x108de7d       48c7042402000000    MOVQ $0x2, 0(SP)
  main.go:12        0x108de85       e8366bf7ff      CALL runtime.GOMAXPROCS(SB)
  main.go:13        0x108de8a       c7042400000000      MOVL $0x0, 0(SP)
  main.go:13        0x108de91       488d05187f0300      LEAQ go.func.*+115(SB), AX
  main.go:13        0x108de98       4889442408      MOVQ AX, 0x8(SP)
  main.go:13        0x108de9d       e8fe13faff      CALL runtime.newproc(SB)
  main.go:20        0x108dea2       ebfe            JMP 0x108dea2
  main.go:11        0x108dea4       e8c7dffbff      CALL runtime.morestack_noctxt(SB)
  main.go:11        0x108dea9       ebb5            JMP main.main(SB)
  :-1           0x108deab       cc          INT $0x3
  :-1           0x108deac       cc          INT $0x3
  :-1           0x108dead       cc          INT $0x3
  :-1           0x108deae       cc          INT $0x3
  :-1           0x108deaf       cc          INT $0x3

TEXT main.main.func1(SB) /Users/liudanking/code/golang/gopath/src/test/main.go
  main.go:13        0x108deb0       65488b0c25a0080000  MOVQ GS:0x8a0, CX
  main.go:13        0x108deb9       483b6110        CMPQ 0x10(CX), SP
  main.go:13        0x108debd       0f8695000000        JBE 0x108df58
  main.go:13        0x108dec3       4883ec58        SUBQ $0x58, SP
  main.go:13        0x108dec7       48896c2450      MOVQ BP, 0x50(SP)
  main.go:13        0x108decc       488d6c2450      LEAQ 0x50(SP), BP
  main.go:15        0x108ded1       0f57c0          XORPS X0, X0
  main.go:15        0x108ded4       0f11442430      MOVUPS X0, 0x30(SP)
  main.go:15        0x108ded9       0f11442440      MOVUPS X0, 0x40(SP)
  main.go:15        0x108dede       488d059b020100      LEAQ runtime.types+65664(SB), AX
  main.go:15        0x108dee5       4889442430      MOVQ AX, 0x30(SP)
  main.go:15        0x108deea       488d0d0f2d0400      LEAQ main.statictmp_0(SB), CX
  main.go:15        0x108def1       48894c2438      MOVQ CX, 0x38(SP)
  main.go:15        0x108def6       488d1583fb0000      LEAQ runtime.types+63872(SB), DX
  main.go:15        0x108defd       48891424        MOVQ DX, 0(SP)
  main.go:15        0x108df01       488d1d107c0c00      LEAQ main.i(SB), BX
  main.go:15        0x108df08       48895c2408      MOVQ BX, 0x8(SP)
  main.go:15        0x108df0d       e84eddf7ff      CALL runtime.convT2E64(SB)
  main.go:15        0x108df12       488b442410      MOVQ 0x10(SP), AX
  main.go:15        0x108df17       488b4c2418      MOVQ 0x18(SP), CX
  main.go:15        0x108df1c       4889442440      MOVQ AX, 0x40(SP)
  main.go:15        0x108df21       48894c2448      MOVQ CX, 0x48(SP)
  main.go:15        0x108df26       488d442430      LEAQ 0x30(SP), AX
  main.go:15        0x108df2b       48890424        MOVQ AX, 0(SP)
  main.go:15        0x108df2f       48c744240802000000  MOVQ $0x2, 0x8(SP)
  main.go:15        0x108df38       48c744241002000000  MOVQ $0x2, 0x10(SP)
  main.go:15        0x108df41       e85a9dffff      CALL fmt.Println(SB)
  main.go:16        0x108df46       48c7042400ca9a3b    MOVQ $0x3b9aca00, 0(SP)
  main.go:16        0x108df4e       e87d27fbff      CALL time.Sleep(SB)
  main.go:15        0x108df53       e979ffffff      JMP 0x108ded1
  main.go:13        0x108df58       e813dffbff      CALL runtime.morestack_noctxt(SB)
  main.go:13        0x108df5d       e94effffff      JMP main.main.func1(SB)
  :-1           0x108df62       cc          INT $0x3
  :-1           0x108df63       cc          INT $0x3
  :-1           0x108df64       cc          INT $0x3
  :-1           0x108df65       cc          INT $0x3
  :-1           0x108df66       cc          INT $0x3
  :-1           0x108df67       cc          INT $0x3
  :-1           0x108df68       cc          INT $0x3
  :-1           0x108df69       cc          INT $0x3
  :-1           0x108df6a       cc          INT $0x3
  :-1           0x108df6b       cc          INT $0x3
  :-1           0x108df6c       cc          INT $0x3
  :-1           0x108df6d       cc          INT $0x3
  :-1           0x108df6e       cc          INT $0x3
  :-1           0x108df6f       cc          INT $0x3

显然,

    for {
        i += 1
    }

直接被优化没了。我们可以在语句 i += 1 添加一个其他语句来避免被优化掉:

    for {
        i += 1
        time.Sleep(time.Nanosecond)
    }

重新运行程序,运行结果“看似正确”了:

╰─➤  go run main.go                                                                                                                                     1 ↵
i am here 30
i am here 1806937
i am here 3853635
i am here 5485251
...

显然,如此修改之后,这段代码并非真正正确。因为变量 i 存在并发读写,即 data race 的问题。而 data race 场景下,go 的行为是未知的。程序员最讨厌的几件事中,不确定性必居其一。因此,一步小心写出 data race 的bug,调试起来是不太开心的。这里的例子因为只有几行代码,我们可以目测定位问题。如果代码规模比较大,我们可以借助 golang 工具链中的 -race 参数来排查该类问题:

╰─➤  go run -race main.go                                                                                                                               2 ↵
==================
WARNING: DATA RACE
Read at 0x0000011d4318 by goroutine 6:
  runtime.convT2E64()
      /usr/local/go/src/runtime/iface.go:335 +0x0
  main.main.func1()
      /Users/liudanking/code/golang/gopath/src/test/main.go:15 +0x7d

Previous write at 0x0000011d4318 by main goroutine:
  main.main()
      /Users/liudanking/code/golang/gopath/src/test/main.go:20 +0x7f

Goroutine 6 (running) created at:
  main.main()
      /Users/liudanking/code/golang/gopath/src/test/main.go:13 +0x53
==================
i am here 1
i am here 558324
i am here 1075838

除了在 go run 上可以使用 -trace, 其他几个常用的golang工具链指令也支持这个参数:

$ go test -race mypkg    // to test the package
$ go run -race mysrc.go  // to run the source file
$ go build -race mycmd   // to build the command
$ go install -race mypkg // to install the package

需要说明的是, -trace 并不保证能够检查出程序中所有的 data race, 而检查出 data race 则必然存在。说起来比较绕,大家记住它跟布隆过滤器 (Bloom Filter) 的真值表是一样的就对了。

而要把最开始提到的代码改对,方法有很多,我们可以使用 The Go Memory Model 推荐的 sync 包中的读写锁即可:

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

var i = 0

func main() {
    runtime.GOMAXPROCS(2)
    mtx := sync.RWMutex{}
    go func() {
        for {
            mtx.RLock()
            fmt.Println("i am here", i)
            mtx.RUnlock()
            time.Sleep(time.Second)
        }
    }()
    for {
        mtx.Lock()
        i += 1
        mtx.Unlock()
        time.Sleep(time.Nanosecond)
    }

扩展阅读

QUIC 存在 UDP 反射 DDoS 攻击漏洞吗?

今年年初,360信息安全部发布了一篇关于利用 UDP 反射 DDoS 的分析报告:Memcache UDP反射放大攻击技术分析。报告一出,引起了业界的普遍关注。根据文中所述,光是Qrator Labs 在 medium.com 上 批露的一次DDoS攻击看,其攻击流量峰值达到 480Gbps。而360信息安全团队本身也监测和确认有更大的攻击已经实际发生,只是未被公开报道。

而就在这个这个事件纰漏没多久,我把博客升级为支持基于 UDP 的 QUIC 协议来改善小站的访问体验:本站开启支持 QUIC 的方法与配置。本着小站没几人访问的蜜汁自信,当时也没太纠结 QUIC 是否也存在 UDP 反射漏洞。前几天,看到著名博主,阮一峰同学网站被 DDoS 攻击,心里咯噔一下:出来混迟早是要还的,还是填坑为安吧。

什么是 UDP 反射 DDoS 攻击

简单讲,就是攻击者利用IP网络不做真实源地址检查的“设计缺陷“,向提供基于 UDP 服务的服务器发送伪造源地址(一般为被攻击者的主机IP)的 UDP 报文请求,使得这些 UDP 报文的响应数据都会发送给被攻击者主机,这种攻击我们称之为 UDP 反射 DDoS 攻击。

之所以要通过被利用的服务器反射流量到被攻击的服务器,是因为被利用的服务器一般存在流量放大效应。即一个伪造IP的 UDP 请求发送到到被利用服务器后,被利用服务器会发送比请求更多的数据到被攻击者服务器。

被利用服务器输出流量与输入流量的比值我们称之为放大系数。这个系数与被利用服务器所提供的 UDP 服务有关。之前提到的利用 Memcache 漏洞的 DRDoS 攻击,可以获得稳定的 60000 倍放大系数。而我们日常使用的 DNS 则可以轻松的获得 50 倍的放大系数。

由放大系数反推,我们可以知道,如果一个 UDP 服务被利用以后,放大系数小于等于1的话,则不存在利用价值,因为这个时候,只从带宽流量方面考虑的话,还不如直接利用攻击主机对被攻击服务器进行攻击效率高。

QUIC 存在 UDP 反射攻击漏洞吗

按照蛤乎惯例,照顾猴急的同学,先给结论:可以。

QUIC 主要通过以下机制来解决该问题:

  1. 对于首次发起建立 QUIC 连接的客户端,服务端要求其初始化的 hello 数据包必须完全填充。这个包在 IPv4 下一般是 1370 字节,在 IPv6 下是 1350 字节。在 QUIC 协议中,服务器和客户端数据交互的基本单位是就是 UDP 数据包,而一个全填充的数据包已经达到了数据包大小的上限,因此服务器的响应数据包一定是小于等于这个 hello 数据包的。显然,放大系数小于等于1. 因此,新连接的建立没有反射利用的价值。
  2. 建立 QUIC 连接后,客户端发出的数据包就不会全填充了。这个时候,如果被 UDP 反射利用,放大系数是大于1的。因此, QUIC 引入了源地址token (source address token):在成功建立 QUIC 连接后,服务器会用发放一个源地址token给客户端,并要求客户端在后续的数据包中带上这个token;服务器只对源地址token有效的数据包进行处理。源地址token中一般包含客户端的源地址和服务器的时间。因此这是一个客户端证明其IP所有权的凭证。
  3. 由于源地址token可能会被网络中的攻击者嗅探收集,因此 QUIC 设计了一套源地址token的过期和刷新机制。另一方面,每次客户端发送的数据包如果都带上源地址token的话,不仅客户端流量大,服务器验证token也是额外的开销,使得协议延迟变高。因此 QUIC 协议允许客户端按照一个动态策略选择是否在数据包中夹带源地址token:服务器端收集和统计源地址的数据包,当统计到源地址数据包交互响应异常的数量超过阈值时,要求该源地址的客户端必须夹带源地址token, 对于无法提供合法源地址的token的请求进行 reject 处理。

扩展阅读

魔鬼在细节中:Base64 你可能不知道的几个细节

Base64 是我们常用的编码方式,广泛用于邮件编码、数据签名/数据校验编码以及HTML/XML复杂数据编码。这本来是一个花两分钟了解一下就无需关注的技术,但是最近线上遇到一个相关问题。于是重新梳理了一下 Base64, 发现了一些以前未曾注意到的细节,记录如下,希望对你也有帮助。

Base64 是什么?

Base64 是一种将二进制数据表示为可打印字符的编码方法。基本操作是将3个字节编码为4个 Base64 单元:3 * 8 bit = 4 * 6 bit. 这种编码方法常用于处理文本数据的场合,例如在 HTML/XML 中表示、传输、存储一些二进制数据(如数据签名、数据校验等)。Base64 编码后数据增长为原来的 4/3 ≈ 1.33 倍。相较于将二进制数据按照十六进制输出数据增长为原来2倍,Base64 更加节省空间。Base64 的标准是 RFC 4648,如果你不想直接阅读这个拗口啰嗦的RFC,可以继续往下看。

Base64 不是什么?

Base64 不是一种加密方式,因此它不提供任何安全特性。我们在论坛、个人博客中发现很多人使用 Base64 编码显示自己邮箱主要是避免被搜索引擎及其他批量化工具发现和索引。

Base64 编码结果是唯一的吗?

不是的。Base64 根据编码字典表不同以及是否 padding (使用=作为 padding 字符),对同一数据的编码结果可能不同。使用最多的字典表有两个:

                      Table 1: The Base 64 Alphabet

     Value Encoding  Value Encoding  Value Encoding  Value Encoding
         0 A            17 R            34 i            51 z
         1 B            18 S            35 j            52 0
         2 C            19 T            36 k            53 1
         3 D            20 U            37 l            54 2
         4 E            21 V            38 m            55 3
         5 F            22 W            39 n            56 4
         6 G            23 X            40 o            57 5
         7 H            24 Y            41 p            58 6
         8 I            25 Z            42 q            59 7
         9 J            26 a            43 r            60 8
        10 K            27 b            44 s            61 9
        11 L            28 c            45 t            62 +
        12 M            29 d            46 u            63 /
        13 N            30 e            47 v
        14 O            31 f            48 w         (pad) =
        15 P            32 g            49 x
        16 Q            33 h            50 y

         Table 2: The "URL and Filename safe" Base 64 Alphabet

     Value Encoding  Value Encoding  Value Encoding  Value Encoding
         0 A            17 R            34 i            51 z
         1 B            18 S            35 j            52 0
         2 C            19 T            36 k            53 1
         3 D            20 U            37 l            54 2
         4 E            21 V            38 m            55 3
         5 F            22 W            39 n            56 4
         6 G            23 X            40 o            57 5
         7 H            24 Y            41 p            58 6
         8 I            25 Z            42 q            59 7
         9 J            26 a            43 r            60 8
        10 K            27 b            44 s            61 9
        11 L            28 c            45 t            62 - (minus)
        12 M            29 d            46 u            63 _
        13 N            30 e            47 v           (underline)
        14 O            31 f            48 w
        15 P            32 g            49 x
        16 Q            33 h            50 y         (pad) =

这两个字典表的区别主要是 6263 使用的字符不同(我们将这两个字符称为特殊字符)。因此,对于同一数据最多可能有 2 * 2 = 4 种编码结果。以 0x0F0xF1(2 bytes)为例,有以下4种编码结果:

  • 字典表1 + padding: D/E=
  • 字典表1 + nopadding: D/E
  • 字典表2 + padding: D_E=
  • 字典表2 + nopadding: D_E

这种一个数据有多个编码结果的情况,往往会给我们解码带来困扰。因此,在使用 Base64 的场景中,务必在文档中注明你是使用的哪一个字典表以及是否需要 padding. 当然,国内环境对文档普遍不够重视,在这么小的技术点上写如此细致是不敢奢望的,有一条有用的经验是:在没有特殊说明的情况下,技术文档中的 Base64 一般是指 字典表1 + padding.

Base64 是 url/filename safe 的吗?

如上所诉,我们默认的 Base64 编码使用的是字典表1,而这个字典表中的字符 +/ 无论是在url还是文件系统中都是特殊字符。因此,基于字典表1的Base64编码不是 url/filename safe 的,不能将该 Base64 编码直接与url拼接或用来命名文件。基于字典表2的 Base64 编码是 url/filename safe 的。但它不是我们大部分编程语言的默认字典,因此,你如果选择这个字典进行 Base64 编码,在解码时也选择该字典。否则你有很大概率会遇到部分数据能解码,部分数据不能解码的问题。

Base64 可以自定义特殊字符吗?

字典表2使用了不一样的 6263 特殊字符以实现 url/filename safe. 但这可能无法满足所有应用场景。因此根据 RFC, 你是可以自定义这两个特殊字符建立自己的字典表的。有一点需要注意,如果你使用了自定义字典表,那么请确保自己编码和解码使用的字典表是一致的。

Base64 编码结果中的等号(=)可以省略吗?是多余的设计吗?

可以省略,但不是多余的设计。

我们先看为何可以省略:

对于数据 A, 如果我们省略padding的等号,解码的时候我们从QQ是可以推断出来,原始数据长度必然是1 byte, 因此可以可以正确解码。数据 BC 同理。

既然 padding 的等号完全不影响解码,是否可以取消这个设计呢?答案是否定的。对于一些将多个Base64编码拼接在一起的场景,padding的等号可以标记一个 Base64 编码单元的边界,使得拼接后的 Base64 编码整体是可以无歧义正确解码的。如果省略等号,则无法保证无歧义性。我们看一个例子:

  • I Base64编码为 SQ (SQ== with padding)
  • AM Base64编码为 QU0 (QU0= with padding)
  • Daniel Base64编码为 RGFuaWVs (RGFuaWVs with padding)

如果使用省略等号的方式,拼接后的Base64编码是 SQQU0RGFuaWVs, 因为我们无法区分边界,我们只能对整个字符串进行解码,显然解码结果是不正确的。如果我们不省略等号,则拼接后的编码 SQ==QU0=RGFuaWVs 可以根据等号区分边界,然后分块正确解码。

扩展阅读

什么车最适合跑滴滴——数据化思维小记

背景

端午节回村里,发小问买个车跑滴滴应该选什么车。在发小眼里,跟互联网相关的,我应该都懂……但是,我也就是滴滴伪司机,2015年注册以来就跑过一单。我虽然也喜欢车,但是也深知车这东西到手就开始贬值,实实在在的负债。看着他手里几十万预算的拆迁款,回想一下平时跟他吹牛皮的聊天记录,感觉这家伙就是趁着端午想让我去他家说服叔叔们同意他买个 BMW 330 曜夜版。这个坑我怎么可能跳?

我跟他分析了N条跑滴滴一定是要选低油耗、养护成本低、贬值率低的车。但是始终说服不了他。尝试从网上搜了一下什么车适合跑滴滴,结果大概分三种,一种是明显的车托写的软文,直接pass。一种是凭自己主观感受推荐的车型,这种个人感情色彩太强烈,难以客观,容易好心办坏事。还有一种是给出了选车的方法,但是没有给出具体车型,例如:

你只要多做几次快车,就会发现什么车最多。

多做几次车,和司机师傅聊聊,就知道什么车合适了。

出处:大学毕业,想专职跑滴滴,什么车比较合适?

第三种类型的信息是最有用的,毕竟授之以鱼不如授之以渔。按照上面的思路,无非就是需要搜集点数据,然后得出结论。正在苦恼数据的时候,想起我经常使用滴滴加油薅羊毛,里面可以看到滴滴车主的实时订单和近30日回头客的加油统计,数据简直就是现成的。于是,我决定用实际数据给发小上一课。

如何获取数据

我们要获取的数据位于 滴滴车主 APP -> 滴滴加油 页面:

页面中,我们可以看到滴滴加油站的实时订单和30日回头客两个非常有用的数据。数据获取阶段,我们的任务就是把这两个数据抓下来保存到本地。

这个页面是一个web页面,但是会验证动态token。考虑到我们只是简单的收集一点数据,因此不想正面逆向token的生成方式,仅仅通过一个小工具进行中间人攻击的方式就可以收集数据:

这个小工具 didi-car-rank 放在了github上,感兴趣的同学可以安装玩一玩。

如何解读数据

我们假设滴滴司机中跑得最勤快、数量最多的车型是最适合跑滴滴的车型。而我们采集的数据只有两项:实时订单和30日回头客。因此,简单统计一下我们采样的实时订单和和30日回头客加油次数即可。以成都为例,统计结果如下:

车型订单数量排名 统计了我们获取到的成都市各个加油站实时加油订单中各个车型的数量。它反映了车型的车型保有量,准确讲是车型在滴滴车主中的保有量。一般来说,保有量越大的车,其维修成本越低,保值率越高,当然这不绝对,相关性大抵如此。由于我们采集实时订单的时间不长,因此统计中的绝对数字不是那么重要,主要关注相对值。

车型加油积分排行 统计了成都市各个加油站近30天滴滴加油次数前50名的各个车型次数(1次=1积分)。它反映了滴滴专职司机(绝大部分)使用的车型。有了积分以后,用积分除以上面的订单数量,就可以得到每个车型的平均积分。这个指标在一定程度上反映了滴滴的冠军车型。

滴滴分为快车和专车。其中,专车基本被凯美瑞、天籁、帕萨特三分天下,因此没什么可分析的,看个人喜好买就对了。我们主要看一下快车车型。按照上面的思路,我们是否该选快车中平均得分最高的长安逸动呢?答案是否定的,因为这款车型的加油订单数量只有17(图中未入榜),也就是说长安逸动的保有量并不大,很可能是因为车价便宜,而被少数人用来专职跑滴滴拉高了平均分,选择这种车型后期的保值率会有较大问题。类似的,也可以排除平均分为45.33的别克凯越。如果不介意保值率,准备把这个车开滴滴开到报废,倒是可以考虑这样的车型。

我们尽量考虑实时订单数加油积分平均积分都在top 10的车型。因为这可以相对全面的放映车型在油耗、养护成本、保值率、可靠性等方面的实际认可程度。丰田卡罗拉、大众朗逸、大众新捷达、斯柯达明锐、日产轩逸都是不错的选择。考虑到油耗是实际运营中的长期成本,且占比较高,给发下及其家人安利的了丰田卡罗拉双擎,一箱油跑1000KM不是梦。同时也成功破除了发小实现蓝天白云的小心思。希望下次回村里的时候他不会上来跟我干架,毕竟我也是良苦用心……

需要说明的是,上面的选择是成都地区一个相对合理的选择。对于其他城市,由于受地域政策(区域车企保护、限行、新能源扶持)的影响,这款车型可能并不适合你所在的城市。比如,根据我们抓取到的数据,我们发现在上海市最受滴滴司机青睐的车型是荣威550/e550和比亚迪秦,重庆最火的是长安逸动。

附录给出了国内热门城市的滴滴热门车型排名数据,可供参考。如果附录数据没有你的城市,你也可以使用小工具 didi-car-rank 自行抓取数据进行分析。

小结

在数字化如此发达的今天,数据就是资源。利用数据分析事物的相关性能在很大程度上辅助我们减少或消除不确定性,提高我们判断的准确性。所谓的数据指导运营背后的思路也是如此。同时也应该意识到,大部分时候我们获取的数据往往是含有噪音的。因此,要学会使用其他数据来佐证或剔除一些可疑的数据。做到大胆假设,小心求证。

数据的收集可以跟技术没有半毛钱关系。比如,如果我们不通过数据抓取也可以通过直接到加油站蹲点数车型的方式来获取数据。这个跟你要开一个奶茶店,去线下数人头、算人流、估流水是一样的。商业本质的东西其实一直没有大的改变,有时候甚至会让你觉得很low.

很多时候,人们凭经验估计的数据偏差是非常大的。比如,在抓取一个城市的加油站数据的时候,第一直觉是一个城市的加油站太多了,抓数据会不会累死(因为需要手指滑动来获取数据)。但是,实际发现,北京的滴滴加油站也才不过在3位数,上海因为一直在给滴滴这类网约车平台开罚单,只有区区十几个滴滴加油站。我们常常说一个好的产品经理要有清晰的思维来估计一个城市有多少个理发师,对于研发而言,这个数据化分析和思考的能力一样重要。因为它可能决定了你对系统关键部分的设计和选型。

其实,滴滴加油站的数据除了分析滴滴的热门车型,如果你是滴滴的竞争对手,是不是可以监控/分析/对比一下……(此处略去几个字);如果你是整车厂,是不是可以更加有的放矢呢?如果你是汽车后市场从业者,是不是看到了其他机会呢?数据本身是冰冷的,但是你的敏感性却可以使之发光发热。在这一点上,我还是一个没入门的初学者。共勉之。

附录:国内热门城市滴滴热门车型排名

北京市

车型订单数量排名:
+------+--------------+------------+
| 排名 |     车型     | 实时订单数 |
+------+--------------+------------+
|    1 | 大众朗逸     |        126 |
|    2 | 日产阳光     |        116 |
|    3 | 大众新捷达   |        113 |
|    4 | 大众帕萨特   |         97 |
|    5 | 日产天籁     |         89 |
|    6 | 日产轩逸     |         85 |
|    7 | 丰田凯美瑞   |         81 |
|    8 | 大众速腾     |         71 |
|    9 | 丰田卡罗拉   |         70 |
|   10 | 现代悦动     |         68 |
|   11 | 本田雅阁     |         68 |
|   12 | 大众宝来     |         65 |
|   13 | 起亚K2       |         64 |
|   14 | 现代朗动     |         62 |
|   15 | 大众新桑塔纳 |         50 |
|   16 | 马自达CX-5   |         50 |
|   17 | 大众迈腾     |         48 |
|   18 | 别克GL8      |         45 |
|   19 | 雪佛兰科鲁兹 |         43 |
|   20 | 起亚K3       |         43 |
+------+--------------+------------+

车型加油积分排名:
+------+--------------+----------+----------+
| 排名 |     车型     | 加油积分 | 平均积分 |
+------+--------------+----------+----------+
|    1 | 出租车       |    17804 | N/A      |
|    2 | 大众朗逸     |     6547 |    51.96 |
|    3 | 日产阳光     |     4305 |    37.11 |
|    4 | 现代悦动     |     2827 |    41.57 |
|    5 | 起亚K2       |     2672 |    41.75 |
|    6 | 日产轩逸     |     2628 |    30.92 |
|    7 | 现代朗动     |     2483 |    40.05 |
|    8 | 大众帕萨特   |     2309 |    23.80 |
|    9 | 丰田凯美瑞   |     2159 |    26.65 |
|   10 | 日产天籁     |     2131 |    23.94 |
|   11 | 雪佛兰科沃兹 |     1942 |    57.12 |
|   12 | 丰田卡罗拉   |     1855 |    26.50 |
|   13 | 大众新捷达   |     1809 |    16.01 |
|   14 | 雪佛兰科鲁兹 |     1806 |    42.00 |
|   15 | 大众宝来     |     1670 |    25.69 |
|   16 | 起亚K3       |     1584 |    36.84 |
|   17 | 本田雅阁     |     1387 |    20.40 |
|   18 | 丰田威驰     |     1255 |    33.03 |
|   19 | 别克凯越     |     1218 |    29.71 |
|   20 | 别克英朗     |     1206 |    70.94 |
+------+--------------+----------+----------+

上海市

车型订单数量排名:
+------+---------------+------------+
| 排名 |     车型      | 实时订单数 |
+------+---------------+------------+
|    1 | 比亚迪秦      |         37 |
|    2 | 荣威550       |         37 |
|    3 | 荣威e550      |         16 |
|    4 | 荣威ei6       |         13 |
|    5 | 别克GL8       |          9 |
|    6 | 别克英朗      |          8 |
|    7 | 日产轩逸      |          6 |
|    8 | 丰田卡罗拉    |          6 |
|    9 | 华晨华颂华颂7 |          6 |
|   10 | 斯柯达明锐    |          6 |
|   11 | 本田思域      |          5 |
|   12 | 荣威950       |          5 |
|   13 | 大众新桑塔纳  |          5 |
|   14 | 丰田雷凌      |          5 |
|   15 | 福特福克斯    |          4 |
|   16 | 大众帕萨特    |          4 |
|   17 | 日产天籁      |          4 |
|   18 | 荣威350       |          4 |
|   19 | 上汽G10       |          4 |
|   20 | 大众宝来      |          3 |
+------+---------------+------------+

车型加油积分排名:
+------+---------------+----------+----------+
| 排名 |     车型      | 加油积分 | 平均积分 |
+------+---------------+----------+----------+
|    1 | 荣威550       |     4668 |   126.16 |
|    2 | 比亚迪秦      |     1940 |    52.43 |
|    3 | 荣威e550      |     1930 |   120.62 |
|    4 | 荣威ei6       |     1178 |    90.62 |
|    5 | 华晨华颂华颂7 |      812 |   135.33 |
|    6 | 日产轩逸      |      687 |   114.50 |
|    7 | 别克GL8       |      377 |    41.89 |
|    8 | 荣威950       |      271 |    54.20 |
|    9 | 大众帕萨特    |      255 |    63.75 |
|   10 | 出租车        |      232 | N/A      |
|   11 | 大众朗逸      |      201 |    67.00 |
|   12 | 丰田卡罗拉    |      197 |    32.83 |
|   13 | 丰田凯美瑞    |      196 |    65.33 |
|   14 | 上汽G10       |      186 |    46.50 |
|   15 | 丰田雷凌      |      170 |    34.00 |
|   16 | 别克英朗      |      157 |    19.62 |
|   17 | 福特福克斯    |      128 |    32.00 |
|   18 | 荣威e950      |      125 |   125.00 |
|   19 | 别克君威      |      125 |    62.50 |
|   20 | 本田锋范      |      118 |    59.00 |
+------+---------------+----------+----------+

广州市

+------+--------------+------------+
| 排名 |     车型     | 实时订单数 |
+------+--------------+------------+
|    1 | 日产轩逸     |         88 |
|    2 | 丰田卡罗拉   |         79 |
|    3 | 比亚迪秦     |         77 |
|    4 | 日产逍客     |         47 |
|    5 | 丰田雷凌双擎 |         43 |
|    6 | 本田凌派     |         43 |
|    7 | 日产天籁     |         42 |
|    8 | 福特麦柯斯   |         42 |
|    9 | 丰田雷凌     |         41 |
|   10 | 捷豹XF       |         37 |
|   11 | 本田锋范     |         36 |
|   12 | 丰田凯美瑞   |         35 |
|   13 | 本田飞度     |         24 |
|   14 | 宝骏560      |         23 |
|   15 | 日产阳光     |         21 |
|   16 | 本田雅阁     |         20 |
|   17 | 丰田威驰     |         20 |
|   18 | 吉利帝豪     |         19 |
|   19 | 荣威950      |         19 |
|   20 | 别克英朗     |         18 |
+------+--------------+------------+


车型加油积分排名:
+------+--------------+----------+----------+
| 排名 |     车型     | 加油积分 | 平均积分 |
+------+--------------+----------+----------+
|    1 | 比亚迪秦     |     3702 |    48.08 |
|    2 | 日产轩逸     |     3465 |    39.38 |
|    3 | 出租车       |     2891 | N/A      |
|    4 | 丰田卡罗拉   |     2419 |    30.62 |
|    5 | 日产天籁     |     2294 |    54.62 |
|    6 | 本田凌派     |     1822 |    42.37 |
|    7 | 丰田凯美瑞   |     1311 |    37.46 |
|    8 | 丰田雷凌     |     1228 |    29.95 |
|    9 | 丰田雷凌双擎 |     1164 |    27.07 |
|   10 | 本田锋范     |      907 |    25.19 |
|   11 | 宝骏560      |      633 |    27.52 |
|   12 | 本田飞度     |      526 |    21.92 |
|   13 | 日产轩逸经典 |      519 |    51.90 |
|   14 | 本田雅阁     |      486 |    24.30 |
|   15 | 现代朗动     |      479 |    26.61 |
|   16 | 起亚K3       |      430 |    30.71 |
|   17 | 丰田威驰     |      418 |    20.90 |
|   18 | 本田思域     |      416 |    32.00 |
|   19 | 广汽传祺GS4  |      412 |    58.86 |
|   20 | 日产阳光     |      396 |    18.86 |
+------+--------------+----------+----------+

深圳市

车型订单数量排名:
+------+----------------+------------+
| 排名 |      车型      | 实时订单数 |
+------+----------------+------------+
|    1 | 丰田卡罗拉     |         92 |
|    2 | 日产轩逸       |         70 |
|    3 | 福特麦柯斯     |         39 |
|    4 | 日产逍客       |         38 |
|    5 | 比亚迪秦       |         37 |
|    6 | 日产天籁       |         27 |
|    7 | 现代朗动       |         25 |
|    8 | 捷豹XF         |         24 |
|    9 | 宝骏560        |         24 |
|   10 | 日产全新轩逸   |         22 |
|   11 | 丰田凯美瑞     |         22 |
|   12 | 丰田卡罗拉双擎 |         21 |
|   13 | 日产轩逸经典   |         20 |
|   14 | 本田凌派       |         19 |
|   15 | 起亚K3         |         18 |
|   16 | 大众朗逸       |         18 |
|   17 | 丰田威驰       |         17 |
|   18 | 本田雅阁       |         16 |
|   19 | 丰田雷凌       |         16 |
|   20 | 日产阳光       |         16 |
+------+----------------+------------+

车型加油积分排名:
+------+----------------+----------+----------+
| 排名 |      车型      | 加油积分 | 平均积分 |
+------+----------------+----------+----------+
|    1 | 日产轩逸       |     5569 |    79.56 |
|    2 | 丰田卡罗拉     |     4988 |    54.22 |
|    3 | 比亚迪秦       |     3269 |    88.35 |
|    4 | 丰田凯美瑞     |     1824 |    82.91 |
|    5 | 本田凌派       |     1790 |    94.21 |
|    6 | 日产天籁       |     1512 |    56.00 |
|    7 | 丰田雷凌       |     1360 |    85.00 |
|    8 | 大众朗逸       |     1342 |    74.56 |
|    9 | 日产轩逸经典   |     1266 |    63.30 |
|   10 | 荣威ei6        |     1221 |    81.40 |
|   11 | 宝骏560        |     1139 |    47.46 |
|   12 | 丰田威驰       |     1071 |    63.00 |
|   13 | 别克凯越       |      968 |    80.67 |
|   14 | 本田锋范       |      808 |    53.87 |
|   15 | 日产阳光       |      768 |    48.00 |
|   16 | 丰田卡罗拉双擎 |      716 |    34.10 |
|   17 | 本田雅阁       |      697 |    43.56 |
|   18 | 现代朗动       |      676 |    27.04 |
|   19 | 现代悦动       |      609 |    40.60 |
|   20 | 丰田雷凌双擎   |      572 |    71.50 |
+------+----------------+----------+----------+

杭州市

车型订单数量排名:
+------+--------------+------------+
| 排名 |     车型     | 实时订单数 |
+------+--------------+------------+
|    1 | 比亚迪秦     |         46 |
|    2 | 别克凯越     |         18 |
|    3 | 大众朗逸     |         14 |
|    4 | 日产轩逸     |         14 |
|    5 | 日产轩逸经典 |         13 |
|    6 | 本田雅阁     |         13 |
|    7 | 丰田卡罗拉   |         11 |
|    8 | 丰田雷凌     |         11 |
|    9 | 日产天籁     |         10 |
|   10 | 别克英朗     |          9 |
|   11 | 现代朗动     |          9 |
|   12 | 荣威950      |          9 |
|   13 | 现代领动     |          8 |
|   14 | 奇瑞艾瑞泽7e |          8 |
|   15 | 荣威ei6      |          8 |
|   16 | 荣威e950     |          7 |
|   17 | 马自达CX-5   |          7 |
|   18 | 现代悦动     |          7 |
|   19 | 宝马X6       |          7 |
|   20 | 雪佛兰科鲁兹 |          6 |
+------+--------------+------------+

车型加油积分排名:
+------+--------------+----------+----------+
| 排名 |     车型     | 加油积分 | 平均积分 |
+------+--------------+----------+----------+
|    1 | 出租车       |     4768 | N/A      |
|    2 | 比亚迪秦     |     3290 |    71.52 |
|    3 | 日产轩逸     |     1345 |    96.07 |
|    4 | 荣威ei6      |      824 |   103.00 |
|    5 | 奇瑞艾瑞泽7e |      631 |    78.88 |
|    6 | 日产轩逸经典 |      593 |    45.62 |
|    7 | 现代朗动     |      573 |    63.67 |
|    8 | 丰田雷凌     |      521 |    47.36 |
|    9 | 荣威e950     |      443 |    63.29 |
|   10 | 丰田卡罗拉   |      424 |    38.55 |
|   11 | 别克凯越     |      419 |    23.28 |
|   12 | 大众朗逸     |      416 |    29.71 |
|   13 | 现代悦动     |      313 |    44.71 |
|   14 | 福特福克斯   |      307 |    51.17 |
|   15 | 雪佛兰科鲁兹 |      301 |    50.17 |
|   16 | 现代领动     |      289 |    36.12 |
|   17 | 起亚K3       |      273 |   136.50 |
|   18 | 本田飞度     |      272 | N/A      |
|   19 | 起亚K2       |      260 |    86.67 |
|   20 | 吉利帝豪     |      259 |    86.33 |
+------+--------------+----------+----------+

成都市

车型订单数量排名:
+------+--------------+------------+
| 排名 |     车型     | 实时订单数 |
+------+--------------+------------+
|    1 | 大众新捷达   |         52 |
|    2 | 斯柯达明锐   |         50 |
|    3 | 大众宝来     |         46 |
|    4 | 大众朗逸     |         42 |
|    5 | 丰田卡罗拉   |         39 |
|    6 | 起亚K3       |         35 |
|    7 | 马自达CX-5   |         30 |
|    8 | 大众速腾     |         29 |
|    9 | 大众新桑塔纳 |         28 |
|   10 | 日产轩逸     |         27 |
|   11 | 标致301      |         24 |
|   12 | 现代瑞纳     |         24 |
|   13 | 福特福克斯   |         23 |
|   14 | 标致408      |         22 |
|   15 | 哈弗H6       |         22 |
|   16 | 雪铁龙爱丽舍 |         21 |
|   17 | 雪佛兰爱唯欧 |         21 |
|   18 | 别克英朗     |         20 |
|   19 | 斯柯达昕锐   |         19 |
|   20 | 大众帕萨特   |         19 |
+------+--------------+------------+

车型加油积分排名:
+------+--------------+----------+----------+
| 排名 |     车型     | 加油积分 | 平均积分 |
+------+--------------+----------+----------+
|    1 | 丰田卡罗拉   |     1613 |    41.36 |
|    2 | 大众朗逸     |     1504 |    35.81 |
|    3 | 斯柯达明锐   |     1452 |    29.04 |
|    4 | 大众宝来     |     1283 |    27.89 |
|    5 | 日产轩逸     |     1232 |    45.63 |
|    6 | 大众速腾     |     1222 |    42.14 |
|    7 | 大众新捷达   |     1146 |    22.04 |
|    8 | 长安逸动     |     1002 |    58.94 |
|    9 | 起亚K3       |      870 |    24.86 |
|   10 | 别克英朗     |      846 |    42.30 |
|   11 | 大众新桑塔纳 |      743 |    26.54 |
|   12 | 现代朗动     |      720 |    42.35 |
|   13 | 丰田凯美瑞   |      719 |    79.89 |
|   14 | 日产天籁     |      694 |    49.57 |
|   15 | 标致301      |      671 |    27.96 |
|   16 | 雪铁龙爱丽舍 |      653 |    31.10 |
|   17 | 福特福克斯   |      570 |    24.78 |
|   18 | 现代瑞纳     |      568 |    23.67 |
|   19 | 别克凯越     |      544 |    45.33 |
|   20 | 斯柯达昕锐   |      527 |    27.74 |
+------+--------------+----------+----------+

重庆市

车型订单数量排名:
+------+-------------+------------+
| 排名 |    车型     | 实时订单数 |
+------+-------------+------------+
|    1 | 标致408     |         19 |
|    2 | 本田凌派    |         16 |
|    3 | 长安逸动    |         16 |
|    4 | 大众朗逸    |         15 |
|    5 | 起亚K3      |         14 |
|    6 | 马自达CX-5  |         14 |
|    7 | 日产天籁    |         14 |
|    8 | 吉利帝豪    |         10 |
|    9 | 奇瑞艾瑞泽5 |         10 |
|   10 | 大众新捷达  |         10 |
|   11 | 别克威朗    |          9 |
|   12 | 名爵锐行    |          9 |
|   13 | 标致308     |          9 |
|   14 | 宝骏560     |          8 |
|   15 | 日产轩逸    |          8 |
|   16 | 荣威i6      |          8 |
|   17 | 吉利帝豪GL  |          7 |
|   18 | 日产阳光    |          7 |
|   19 | 众泰Z560    |          7 |
|   20 | 长安CS35    |          7 |
+------+-------------+------------+

车型加油积分排名:
+------+--------------+----------+----------+
| 排名 |     车型     | 加油积分 | 平均积分 |
+------+--------------+----------+----------+
|    1 | 长安逸动     |      548 |    34.25 |
|    2 | 本田凌派     |      427 |    26.69 |
|    3 | 日产天籁     |      379 |    27.07 |
|    4 | 大众朗逸     |      364 |    24.27 |
|    5 | 起亚K3       |      298 |    21.29 |
|    6 | 日产轩逸     |      287 |    35.88 |
|    7 | 福特福睿斯   |      252 |    42.00 |
|    8 | 本田锋范     |      246 |    49.20 |
|    9 | 丰田卡罗拉   |      233 |    38.83 |
|   10 | 大众新桑塔纳 |      203 |    67.67 |
|   11 | 名爵锐行     |      197 |    21.89 |
|   12 | 丰田凯美瑞   |      183 |    36.60 |
|   13 | 别克威朗     |      175 |    19.44 |
|   14 | 吉利帝豪GL   |      169 |    24.14 |
|   15 | 众泰Z560     |      164 |    23.43 |
|   16 | 宝骏560      |      160 |    20.00 |
|   17 | 雪佛兰科鲁兹 |      159 |    22.71 |
|   18 | 长安CS75     |      157 |    78.50 |
|   19 | 起亚K5       |      155 |    25.83 |
|   20 | 哈弗H6       |      151 |    75.50 |
+------+--------------+----------+----------+

使用迁移学习快速训练识别特定风格的图片

使用迁移学习快速训练识别特定风格的图片

前几天接到一个任务,需要从我们app的feed流中的筛选一些「优质」图片,作为运营同学的精选feed候选池。这里「优质」的参考就是以前运营同学手工筛序的精选feed图片。问题并不难,最容易想到的方向有两个:

  1. 机器学习方向,训练一个能够识别这种「优质」风格图片的模型。
  2. 过滤推荐方向,利用用户来测试feed图片质量(根据点赞、评论、观看张数、停留时间等指标),使用用户来筛选优质feed图片(用户的偏好千奇百怪,筛选结果可能未必如你所想,典型如今日头条……)。

今天我们介绍如何使用机器学习解决这个问题。具体来讲,由于时间紧,任务重,我们决定使用迁移学习来完成这个任务。后面如果有时间,我们也会尝试一下使用用户来过滤和筛选优质图片。

什么是迁移学习

迁移学习 (Transfer learning) 顾名思义就是就是把已学训练好的模型参数迁移到新的模型来帮助新模型训练。考虑到大部分数据或任务是存在相关性的,所以通过迁移学习我们可以将已经学到的模型参数(也可理解为模型学到的知识)通过某种方式来分享给新模型从而加快并优化模型的学习效率不用像大多数网络那样从零学习。

为什么使用迁移学习

  • 很多时候,你可能并没有足够大的数据集来训练模型,更不用说带有高质量标签的数据集了。使用已经训练好的网络,可以降低用于训练的数据集大小要求。
  • 从零开始训练一个深度网络是非常消耗算力和时间的。如果再将模型调整、超参数调整等有点玄学的流程加进去,消耗的时间会更多。对于创业公司来说,很多时候是很难给出这么多的时间预算来解决一个模型问题的。
  • 基于迁移学习训练一个模型往往只需要训练有限的几层网络,或者使用已有网络作为特征生成器,使用常规机器学习方法(如svm)来训练分类器。整体训练时间大幅降低。效果可能不是最好的,但是往往能够在短时间内帮你训练出一个够用的模型,解决当前的实际问题。

也就是说,近几年深度学习的各种突破本质上还是建立在数据集的完善和算力的提升。算法方面的提升带来的突破其实不如前两者明显。如果你是一个开发者,具体到要使用机器学习解决特定问题的时候,你一定想清楚你能否搞定数据集和算力的问题,如果不能,不妨尝试一下迁移学习。

如何进行迁移学习

我们的任务是筛选优质feed图片,其实就是一个优质图片与普通图片的二分类问题。

运营给出的「优质」参考图片:

直观感受是,健身摆拍图、美食图和少量风光照是她们眼中的优质图片😂

运营给出的「普通」参考图片:

直观感受是,屏幕截图和没什么特点的图片被认为是普通图片。

我们迁移学习的过程就是复用训练好的(部分)网络和权重,然后构建我们自己的模型进行训练:

迁移学习在选择预训练网络时有一点需要注意:预训练网络与当前任务差距不大,否则迁移学习的效果会很差。这里根据我们的任务类型,我们选择了深度残差网络 ResNet50, 权重选择imagenet数据集。选择 RetNet 的主要原因是之前我们训练的图片鉴黄模型是参考雅虎开源的 open NSFW , 而这个模型使用的就是残差网络,模型效果让我们影响深刻。完整代码如下(keras + tensorflow):

  • 这里我们仅重新训练了输出层,你也可以根据自己需要添加多个自定义层。
  • 整个训练过程非常快,在Macbook late 2013仅使用CPU训练的情况下,不到一个小时收敛到了82%的准确率。考虑到我们的「优质」图片标签质量不太高的实际情况,这个准确率是可以接受的。
  • 完成训练后,我们使用该模型对生产环境的2000张实时图片进行了筛选,得到85张图片,运营主观打分结果是~50%可用,~25%需要结合多图考虑,其他不符合要求。考虑到我们的任务是辅助他们高效发现和筛选潜在优质图片,这个结果他们还是认可的。部分筛选结果如下:

还可以更简单一点吗?

如果你觉得上面重新训练网络还是太慢、太繁琐,我们还有更简单的迁移学习的方法:将预训练网络作为特征提取器,然后使用机器学习方法来训练分类器。以SVM为例,完成迁移学习只需要两个步骤:

  • 将预训练网络最后一层输出作为特征提取出来:
resnet_model = None

def extract_resnet(x):
  '''
  :param x: images numpy array 
  :return: features
  '''
  global resnet_model
  if resnet_model is None:
    resnet_model = ResNet50(include_top=False,
                            weights='imagenet',
                            input_shape=(image_h, image_w, 3))
  features_array = resnet_model.predict(x)
  return np.squeeze(features_array)

  • 使用特征训练SVM分类器:
def train(positive_feature_file, negative_feature_file):

  p_x = np.load(positive_feature_file)
  n_x = np.load(negative_feature_file)

  p_y = np.ones((len(p_x),), dtype=int)
  n_y = np.ones((len(n_x),), dtype=int) * -1

  x = np.concatenate((p_x, n_x), axis=0)
  y = np.concatenate((p_y, n_y), axis=0)

  x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.2)
  logging.info("train shape:%s", x_train.shape)


  pca = PCA(n_components=512, whiten=True)
  pca = pca.fit(x)
  x_train = pca.transform(x_train)
  x_test = pca.transform(x_test)

  logging.info("train shape:%s", x_train.shape)


  # train
  svm_clf = svm.SVC(kernel='rbf', probability=True, decision_function_shape='ovr')
  svm_clf.set_params(C=0.4)
  svm_clf.fit(x_train, y_train)
  preds = svm_clf.predict(x_train)
  logging.info('train preds %d items, train accuracy:%.04f', len(preds), accuracy_score(preds, y_train))

  preds = svm_clf.predict(x_test)
  logging.info('test preds %d items, test accuracy:%.04f', len(preds), accuracy_score(preds, y_test))

  # joblib.dump(ss, './normal-ss.pkl')
  joblib.dump(pca, './normal-pca.pkl')
  joblib.dump(svm_clf, './normal-clf.pkl')

这个方法之所以有效是因为,迁移学习要求预训练网络与当前任务是相似的,那么最后一层网络的输出可以解释为特征的高度抽象,因此可以使用其作为特征进行分类。

这个方法虽然有效,但是需要准备两个数据集:正样本和负样本。很多时候,我们的任务是识别出我们关心的类别,这个类别我们可以花时间和精力来进行数据集的标注,但是对于我们不关心的类别的数据往往是不易收集的。那么,我们可以只准备一个数据集来训练一个只识别我们关心类别的模型吗?答案是可以的,使用One-class classification即可,一般翻译为异常检测或离群点检测。

如果你熟悉sklearn, 你可以使用svm.OneClassSVM:

  oc_svm_clf = svm.OneClassSVM(gamma=0.011, kernel='rbf', nu=0.08)
  oc_svm_clf.fit(x_train)

  preds = oc_svm_clf.predict(x_train)
  expects = np.ones((len(preds)), dtype=int)
  logging.info('train preds %d items, train accuracy:%.04f', len(preds), accuracy_score(preds, expects))

需要注意的是,One-class classification是一种无监督学习,从实验效果看,使用该方法筛选出来的图片「稳定性」相比前面两个方法稳定性要差。如果要在实际业务中使用该方法,需要仔细调整gamma参数,根据ROC曲线寻找一个相对理想的值。

小结

大多数场景下,受限于数据集、算力和时间限制,很少人是从零开始训练一个深度神经网络的。如果你的任务是解决工程中的某个特定问题,那么迁移学习可能是一个有效的高性价比解决方案。你可以使用通过添加或移除若干预训练网络层来实现迁移学习,也可以将预训练网络作为特征提取器,然后使用其他分类方法进行机器学习。迁移学习的效果往往不如完全训练整个网络的效果好,因此,你需要结合具体任务来权衡准确率和成本。

扩展阅读

What a May Day

周四晚上跟生日的父母吃过晚饭后,傻乎乎的带着小梦梦在孩子王逛,一点没有意识到已经是五月的最后一天。有时候,记录的习惯更多的就是提醒自己,时间的昼夜不舍。

这个月有意识的接触了挺多人,对于鄙人这种社交贫瘠的人来说,这个月花在这方面的时间算是奢侈的了。有老友也有新朋友。一次跟新朋友印象深刻的夜谈。已经很久没有跟新认识的人如此没有负担的沟通和交流了。对了,上一次有意思的聊天也是去年的这个时候。初夏,真是一个神奇的时节,一切都开始要变得明亮而耀眼。

五月在做和要做的事情越来越多,一种此情无计可消除,才下眉头,却上心头之感。一部分算是甜蜜的负担,而立之年,一些事情逐渐跟自己是否准备好已经没有必然关系,而是直接去解决它就对了。一部分是多过去时光的辜负,亡羊补牢,希望犹未晚矣。前天看韩老师的5X兴趣社区,看到李伟龙一个视频的幕后花絮,对话挺走心的:时间只会让你老去,其他什么都不会带来;只有你想改变的时候,你才能改变。

神奇的五月,居然达成了跑渣的第一次5公里(一个都不好意思提的配速)。从去年4月份参加跑团开始跑步,到现在已经一年多了,跑步成绩上没有任何提升,也是我预料之中的。对于这件事,我其实想得很明白:我一点都不喜欢运动,但是要支撑我的情怀和要做的事情,我必须要有这个练习和准备。显然,如果保持当前的做事的节奏,也许一周一次的跑步很快就无法支撑自己在做的事情,但是只要保持这件事情的惯性,我相信这股力量不会让自己失望。

这个月最喜欢的书是吴军老师的《智能时代》。因为一直在订阅吴军老师在得到的专栏,因此书中的很多内容其实都在专栏中听过了(如此说来,维护专栏及时高产如母猪,也是需要有存货当备份的🤣)。有两点体会最深:

  1. 人类文明发展是一个不短加速的过程,每一次加速都会让已有产业与新结束结合形成形成新的产业,赶上这个浪潮的会以数量级的优势领先,赶不上或者不愿拥抱变革的则会被无情的淘汰。
  2. 大数据和AI是当前最有可能成为下一个时代的蒸汽机和电。超越时代是困难的,但是从思维方式上则是可以刻意练习大数据和AI思维的。对于程序员而言,这尤为重要——有很大可能性,这决定了当前的你是成为为工业时代的码农,还是智能时代的工程师。

六月会迎来自己在两个月前设定的一个deadline, 从目前看来,不容乐观。可能当时在设定这个目标的时候,其实内心的真实独白就已经是法乎其中则得其下,法乎其上则得其中。但是,总的来说,过去的两个月无论是还在发生还是已经发生的事,多少带来了一丝丝改变。

期待六月,不负好时光。

微信小程序文件上传二三事

这段时间陆陆续续上了好几个微信小程序,功能上都会用到文件上传功能(头像上传、证件照上传等)。在APP上传文件到云端的正确姿势中,我们介绍了我们认为安全的上传流程:

即将密钥保存在服务器,客户端每次向服务器申请一个一次性的signature,然后使用该signature作为凭证来上传文件。一般情况下,向阿里云OSS上传内容,又拍云作为灾备。

随着大家安全意识的增强,这种上传流程几乎已经成为标准姿势。但是,把这个流程在应用到微信小程序却有很多细节需要调整。这里把踩过的坑记录一下,希望能让有需要的同学少走弯路。

微信小程序无法直接读取文件内容进行上传

在我们第一版的上传流程方案中,我们的cds 签名发放服务只实现了阿里云 PutObject 接口的signature发放. PutObject 上传是直接将需要上传的内容以二进流的方式 PUT 到云储存。

但是,微信小程序提供的文件上传API wx.uploadFile 要求文件通过 filePath 提供:

另一方面,微信小程序的 JS API 当前还比较封闭,无法根据 filePath 读取到文件内容,因此也无法通过 wx.request 直接发起网络请求的方式来实现文件上传。

考虑到 wx.uploadFile 本质上是一个 multipart/form-data 网络请求的封装,因此我们只需要实现一个与之对应的签名发放方式接口。阿里云OSS对应的上传接口是 PostObject, 又拍云对应的是其 FORM API. 以阿里云OSS为例,cds 服务生成signature 代码如下:

func GetDefaultOSSPolicyBase64Str(bucket, key string) string {
    policy := map[string]interface{}{
        "expiration": time.Now().AddDate(3, 0, 0).Format("2006-01-02T15:04:05.999Z"),
        "conditions": []interface{}{
            map[string]string{
                "bucket": bucket,
            },
            []string{"starts-with", "$key", key},
        },
    }
    data, _ := json.Marshal(&policy)
    return base64.StdEncoding.EncodeToString(data)
}

func GetOSSPostSignature(secret string, policyBase64 string) string {
    h := hmac.New(sha1.New, []byte(secret))
    io.WriteString(h, policyBase64)
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

小程序端代码如下:

//使用说明
/**
 * 1、引入该文件:const uploadFile = require('../../common/uploadAliyun.js');
 * 2、调用如下:
 * uploadImg: function () {
        const params = {
            _success: this._success
        }
        uploadFile.chooseImg(params);
    },
    _success: function(imgUrl){
        this.setData({
            cover_url: imgUrl,
        })
    },
*/

const uploadFile = {
    _fail: function(desc) {
        wx.showToast({
            icon: "none",
            title: desc
        })
    },
    _success: function() {},
    chooseImg: function(sendData) {
        //先存储传递过来的回调函数
        this._success = sendData._success;
        var that = this;
        wx.chooseImage({
            count: 1,
            sizeType: ['original', 'compressed'],
            sourceType: ["album", "carmera"],
            success: function (res) {
                that.getSign(res.tempFilePaths[0]);
            },
            fail: function (err) {
                wx.showToast({
                    icon: "none",
                    title: "选择图片失败" + err
                })
            }
        })
    },
    //获取阿里上传图片签名
    getSign: function (path) {
        var that = this;
        wx.request({
            url: 'https://somewhere/v2/cds/apply_upload_signature',
            method: 'POST',
            data: {
                "content_type": "image/jpeg",
                "signature_type": "oss_post",
                "business": "xiaochengxu",
                "file_ext": '.jpeg',
                "count": '1'
            },
            success: function (res) {
                let getData = res.data.data[0];
                that.startUpload(getData, path);
            },
            fail: function (err) {
                that._fail("获取签名失败" + JSON.stringify(err))
            }
        })
    },
    //拿到签名后开始上传
    startUpload: function (getData, path) {
        var that = this;
        this.uploadAliYun({
            filePath: path,
            dir: 'wxImg/',
            access_key_id: getData.oss_ext_param.access_key_id,
            policy_base64: getData.oss_ext_param.policy_base64,
            signature: getData.signature,
            upload_url: getData.upload_url,
            object_key: getData.oss_ext_param.object_key,
            content_url: getData.content_url.origin 
        })
    },
    uploadAliYun: function(params) {
        var that = this;
        // if (!params.filePath || params.filePath.length < 9) {
        if (!params.filePath) {
            wx.showModal({
                title: '图片错误',
                content: '请重试',
                showCancel: false,
            })
            return;
        }
        const aliyunFileKey = params.dir + params.filePath.replace('wxfile://', '');

        const aliyunServerURL = params.upload_url;
        const accessid = params.access_key_id;
        const policyBase64 = params.policy_base64;
        const signature = params.signature;
        wx.uploadFile({
            url: aliyunServerURL,
            filePath: params.filePath,
            name: 'file',
            formData: {
                'key': params.object_key,
                'policy': policyBase64,
                'OSSAccessKeyId': accessid,
                'Signature': signature
            },
            success: function (res) {
                if (res.statusCode != 204) {
                    that._fail("上传图片失败");
                    return;
                }
                that._success(params.content_url);
            },
            fail: function (err) {
                that._fail(JSON.stringify(err));
            },
        })
    }
}


module.exports = uploadFile;

使用阿里云OSS域名上传失败

解决签名问题后,发现使用阿里云OSS提供的上传域名无法上传成功,在微信后台尝试添加合法域名的时候,惊奇的发现阿里云OSS的域名直接被微信小程序封禁了:

显然是两个神仙在打架,作为草民只能见招拆招。解决办法就是在阿里云OSS -> bucket -> 域名管理 绑定用户域名:

此外,由于微信小程序已经升级为uploadFile的链接必须是https, 因此还需要在绑定用户域名后设置 证书托管

他山之石,可以攻玉

既然微信能够封禁用阿里云OSS的上传域名,那么微信也可以封禁你自定义的域名。根据以往经验(对天发誓,我们不是有意为之,我们也是受害者……),微信封禁域名一般都是一锅端,即发现一个子域名存在违规内容,那么整个域名都会被封禁。因此,一方面要从技术角度对上传的内容及时检查是否合规(如黄图扫描),另一方面提前做好域名规划,将业务接口域名与自定义的文件上传域名分开,这样即使上传域名被一锅端了,不至于是业务完全不可用。

热眼旁观:老罗与锤子

第一次对老罗的全面了解是本科时候的那趟南戴河毕业旅行:出发的时候随便抓了一本书塞书包里——《我的奋斗》。一群毕业屌丝自然坐的是最便宜,但如今想来却最有意思的绿皮火车去的南戴河。在路上和车站上看完了这本书。

与其说这是一本书,不如说这是老罗在新东方几年上课和演讲的语录。即使按照当时的认知,也不会把它归为推荐一类的书。但这并不重要,重要的是,对这个彪悍且还在继续活跃的胖子有了一个相对完整的了解。如今依然不知道那本书是从哪里来,后来遗失到了哪里去。很多事情,奇妙的偶然性让我知道了这个世界上有个有趣并值得持续关注的灵魂。

一共参加过两次老罗的现场演讲。

第一次是研究生一年级,老罗来学校做一个理想主义者的创业故事II的演讲。那时候老罗还没有开始做手机,演讲中用来插科打诨的公司主体是他的英语培训机构。那时候的自己对商业也没有清晰认识和完整理解。只是自己当时也在参与创业,对「理想主义」和「创业者」这两个词是没有任何抵抗力的。仿佛找到了一起参与一场改变世界革命的战友,惺惺相惜,感同身受。当然,也可见当时万众创业对在校学生荼毒之深😂。

那次分享的很多故事依然记忆犹新,但是要说最深刻的应该是:学会站着赚钱。理所当然的道理遇到浮躁的年代,往往知难行易。感谢老罗的那次演讲,深刻地影响了自己后来创业、工作时候处理棘手事情的底限和原则。

第二次是锤子科技在成都举行的 2017 秋季新品发布会。刚得知消息的时候,还是非常意外的,因为的确没有想到在从帝都回到成都工作的第四个年头,能够在成都听一场老罗的现场相声。

也许近两年成都的热度太高了吧,偌大的成都大魔方演艺厅票务放出即售罄。最后还是因为前东家是锤子科技的股东,因此老同事给置办了一张内部票才得以入场,也算是听相声路上的一段小插曲。那次的发布会发布了两个产品,一个是坚果 Pro 2——目的非常明确,坚果 Pro 是锤子迄今为止最畅销的手机,Pro 2 意图百尺竿头,更进一步; 一个是与成都雾霾非常应景的空气净化器——畅呼吸,算是在产品品类上横向扩充的尝试。

两个产品都很棒,但是即使凭借门票有优惠和优先购买权、手上的 iPhone 6 Plus 廉颇老矣的情况下,自己也没有购买这次发布会上的任何一款产品。很多人说,锤子的产品历来都是叫好不叫座,也许是有原因的😅

回到几天前的鸟巢发布会。有人说坚果 R1 中规中矩,毫无亮点;与之结合的 TNT 工作站就是一个笑话。我不想参与这个结论的辩论,一来自己还没有体验过这两个产品,讨论结论为时过早,毫无意义;二来我想我可能是一群人的典型代表:关注老罗和锤子,只因为这是一家老罗牵头的公司。

R1 是创新吗?当然不是。在手机如此同质化的今天,无论是小厂锤子科技还是巨厂苹果,这几年出的产品整体上都达无法满足大家的心理预期。R1 不过是众多改进型产品中的一员。至于老罗说的全世界第一,哎哟喂,都是成年人了,大家平时都在吹牛X,你不能自己吹完不让别人吹不是?

TNT 是创新吗?当然是。我会购买吗?现在当然不会。

Touch and Talk 的交互方式会是次时代的主流人机沟通方式吗?我不确定,这里没有和稀泥的意思。这个问题可以反过来看:如今嘴里含着触屏手机出生的一代,他们对于 touch 的交互几乎是与生俱来的一种操作直觉,如今的键鼠操作一定不是这一代人所有场景下的最终交互归宿,在很多场景下,一定会有新的交互方式来代替当前的这种操作交互。那么它会是 Touch and Talk 吗?这个真的不知道。超越时代是困难的,而在超越时代的产品真正出现的时候,在大众眼中,这种超越往往又是荒诞的。

我知道很多程序员都会调侃说 TNT 写代码可能会是这样的:

关于这一点,我想说,也许应用在现实的生产工具场景中,也许它……真的就是这样的。但是,这其实杠精了。回想一下老罗介绍的几个 TNT 应用场景:表格数据自动处理、keynote 语音辅助编辑、闪念胶囊自动找图和幻灯片生成…… 没有一个是要取代如今的密集生产工具场景。虽然现场 demo 状况频出,老罗也是汗如雨下,但是,我想你不会否认这么几点:老罗的敏感使其非常善于发现问题,他提到的问题和场景你一定不会陌生,他给出的解决方案也许不是你会选择的方法,但是一定是解决了特定用户群的痛点。

有人说,TNT 工作站这种手机 + Dock 的方式根本不是老罗首创,因为好几年前的摩托罗拉和当前的三星都有对应的产品。单从产品形态来说,TNT 的确没有重新发明轮子。甚至加上 Touch 和语音,整个解决方案也必然不是业界首创。

在这一点上,我比较同意吴军老师的第三眼美女以及发明是以最后一个发明家的名字命名的理论。老罗和他的 TNT 会是这个垂直品类的最后一个发明者以及第三眼美女吗?说实话,我并不确定,但是我愿意将一半的可能性投给老罗。

因为,对于老罗,我想我可能是关注老罗若干群体中的某个群体的典型代表:每次老罗的相声都不会错过,但是却几乎未曾真正触动自己的钱包;表面是老罗的粉丝,但是却自诩为独立思考,不是任何人的拥趸;关注老罗,更多的是他所言所行符合了某一群理想主义者的价值观,视其位精神的知己,行动的马前卒;将老罗及其挂帅的锤子科技看做浮躁创业环境下的一股清流,并由衷乐见其成;经常感怀自己,如果老罗失败了,是这个社会对价值的多元筛选和宽容失败了。

TNT 最终会怎样 boom 我们交给时间评判。但是,老罗一直都是那个埋头做事的人,同时更是平地吹B三丈起的奇才。我依然会是一个旁观者,如同观看一场带有完全主观倾向的比赛,向他投与这场比赛所有的希冀和祝福。

:吴军老师第三眼美女理论

第三眼美女当然是相对第一眼美女和第二眼的美女。第一眼美女有什么特点呢?首先,一眼看上去就很漂亮,但是不属于大众。这里面有很多原因,或许是因为她们本身就认为自己是精英人群而非主流人群,抑或是这些人光芒太扎眼,一般人想接近也接近不了她们。总之结果是,大众只能在远距离去欣赏她们了。其次,人有时会看走眼,乍一看很漂亮,接近以后如果发现没有内涵,看到第二眼、第三眼时,未必还能有最初的好印象了。

第二眼美女未必有第一眼的那么天生丽质,因此她们常常需要更懂得时尚细节才能引来周围人欣赏的眼光,但是这样一来,和一个第二眼美女交往的成本就比较高,大众即使心里痒痒的,未必能得到。即使得到了,第二眼美女的脾气未必好,因此双方的蜜月期一过,可能也就形同陌路了。

第三眼美女是属于大众的,她们未必那么显眼,但是如果仔细观察,她们还是不错的。更重要的是,正是因为她们可能没有光鲜的外表,如果依然能够吸引人,那么必定有某种美德或者价值。而对于欣赏这种美德,或者看重这种价值的人来讲,他们对第三眼美女的喜欢会持续很久,除非这种美德和价值不再存在或者过时了。

容器环境下 go 服务性能诊断方案设计与实现

背景

业务上量以后,对程序进行 profiling 性能诊断对很多后端程序员来说就是家常便饭。一个趁手的工具往往能让这个事情做起来事半功倍。

在这方面,go 有着天然的优势:继承 Google’s pprof C++ profiler 的衣钵,从出生就有 go tool pprof 工具。并且,标准库里面提供 runtime/pprofnet/http/pprof 两个package, 使得 profiling 可编程化。

在非容器环境下,我们的研发同学喜欢使用 net/http/pprof 来提供http接口供 go tool pprof 工具进行 profiling:

import _ "net/http/pprof"

func main(){
    ...
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    ...
}

获取 CPU profile 数据:

go tool pprof http://localhost:6060/debug/pprof/profile

但是,当架构逐步演进为微服务架构并使用k8s等容器化技术进行部署以后,这种这种方式面临的问题也越来越多:

  1. 我们生产环境使用k8s进行容器编排和部署。service类型是 NodePort. 因此研发同学无法直接对某个 service 的特定 pod 进行 profiling. 之前的解决方式是:
    1. 如果要诊断的问题是这个service普遍存在的问题,则直接进行 profiling。
    2. 如果要诊断的问题只出现在这个service的某个特定的pod上,则由运维同学定位到该pod所处的宿主机后登录到该容器中进行profiling。耗时耗力,效率低。
  2. 架构微服务化以后,服务数量呈量级增加。以前那种出现问题再去诊断服务现场的方式越来越难满足频率和数量越来越多的profiling需求(很多情况下,我们才做好profiling的准备,问题可能已经过去了……)。我们迫切的需要一种能够在程序出问题时,自动对程序进行profiling的方案,最大可能获取程序现场数据。
  3. 同时,我们希望这种自动profiling机制对程序性能影响尽可能小,并且可以与现有告警系统集成,直接将诊断结果通知到程序的owner.

方案设计与实现

  • 我们使用 heapster 对k8s的容器集群进行监控。并将监控到的时序数据写入influxDB进行持久化。
  • gopprof 是我们容器环境下对其他 go 服务进行性能诊断的核心服务:
    • 通过对influxDB中的监控数据分析,对于异常的pod自动进行 profiling. 当前设置的策略是如果该pod在两个1分钟分析周期内,资源使用率都超过设定的阈值0.8,则触发profiling。
    • gopprof 作为一个服务部署在k8s集群中主要是使其可以通过内网IP直接访问pod的 http profile接口,已实现对特定pod的profiling:
    go tool pprof http://POD_LAN_IP:NodePort/debug/pprof/profile
    
    • gopprof 完成profiling后,会自动生成 profile svg 调用关系图,并将profile 数据和调用关系图上传云存储,并向服务的owner推送诊断结果通知:

    • 由于 gopprof 依赖工具 go tool pprofgraphivz, 因此gopprof的基础镜像需要预装这两个工具。参考Dockerfile
    # base image contains golang env and graphivz
    
    FROM ubuntu:16.04
    
    MAINTAINER Daniel liudan@codoon.com
    
    RUN apt-get update
    RUN apt-get install wget -y
    RUN wget -O go.tar.gz https://dl.google.com/go/go1.9.2.linux-amd64.tar.gz && \
        tar -C /usr/local -xzf go.tar.gz && \
        rm go.tar.gz
    
    ENV PATH=$PATH:/usr/local/go/bin
    
    RUN go version
    
    RUN apt-get install graphviz -y
    
    • gopprof 向研发同学提供了对特定pod以及特定一组pod进行手动profiling的的接口。在解放运维同学生产力的同时,也让研发同学在出现难以复现的问题时,能够有更大可能性获取到程序现场。
    • 在高可用方面,当前只支持部署一个 gopprof pod, 服务可用性依赖于k8s的的auto restart. 后期如果有这方面的需求,可能会修改为依赖于etcd支持多个gopprof pod部署。

小结

gopprof 服务已经在我们内部落地试运行了一段时间,整个上达到了我们的设计预期,并辅助我们发现和解决了一些之前没有意识到的性能问题。由于有一些内部代码依赖,暂时还无法开源出来。但是整个方案所依赖的组件都是通用的,因此你也可以很容易的实现这个方案。如果你对我们实现中的一些细节感兴趣,欢迎评论和留言。

Service Mesh 及其主流开源实现解析

什么是 Service mesh

Service Mesh 直译过来是 服务网格,目的是解决系统架构微服务化后的服务间通信和治理问题。服务网格由 sidecar 节点组成。在介绍 service mesh 之前,我们先来看一下什么是 sidecar.

Sidecar 在软件系统架构中特指边车模式。这个模式的灵感来源于我们生活中的边三轮:即在两轮摩托车的旁边添加一个边车的方式扩展现有的服务和功能。在绝地求生吃鸡游戏中,摩托车是无敌的,应该也与这个模式有关吧😅 这个模式的精髓在于实现了数据面(业务逻辑)控制面的解耦:原来两轮摩托车的驾驶者集中注意力跑赛道,边车上的领航员专注周围信息和地图,专注导航。

具体到微服务架构中,即给每一个微服务实例(也可以是每个宿主机host)同步部署一个 sidecar proxy:

该 sidecar proxy 负责接管对应服务的入流量和出流量。并将微服务架构中以前有公共库、framework实现的熔断、限流、降级、服务发现、调用链分布式跟踪以及立体监控等功能从服务中抽离到该 proxy 中:

当该 sidecar 在微服务中大量部署时,这些 sidecar 节点自然就形成了一个网格:

这就是我们说的 service mesh 了。对 service mesh 有了一个感性认识后,我们看一下 Linkerd 和 Conduit 的作者 William Morgan 在What’s a service mesh? And why do I need one? 中是如何诠释什么是 Service Mesh:

A service mesh is a dedicated infrastructure layer for handling service-to-service communication. It’s responsible for the reliable delivery of requests through the complex topology of services that comprise a modern, cloud native application. In practice, the service mesh is typically implemented as an array of lightweight network proxies that are deployed alongside application code, without the application needing to be aware.

Service Mesh 这个服务网络专注于处理服务和服务间的通讯。其主要负责构造一个稳定可靠的服务通讯的基础设施,并让整个架构更为的先进和 Cloud Native。在工程中,Service Mesh 基本来说是一组轻量级的与应用逻辑服务部署在一起的服务代理,并且对于应用服务是透明的。

Service Mesh的特点

  • 是一个基础设施
  • 轻量级网络代理,应用程序间通讯的中间层
  • 应用程序无感知,对应用程序透明无侵入
  • 解耦应用程序的重试/超时、监控、追踪和服务发现等控制层面的东西

Service Mesh 有哪些开源实现

Service Mesh 的概念从2016年提出至今,已经发展到了第二代。

第一代 service mesh 以 LinkerdEnvoy 为代表。

Linkerd 使用Scala编写,是业界第一个开源的service mesh方案。作者 William Morgan 是 service mesh 的布道师和践行者。Envoy 基于C++ 11编写,无论是理论上还是实际上,后者性能都比 Linkderd 更好。这两个开源实现都是以 sidecar 为核心,绝大部分关注点都是如何做好proxy,并完成一些通用控制面的功能。 但是,当你在容器中大量部署 sidecar 以后,如何管理和控制这些 sidecar 本身就是一个不小的挑战。于是,第二代 Service Mesh 应运而生。

第二代service mesh主要改进集中在更加强大的控制面功能(与之对应的 sidecar proxy 被称之为数据面),典型代表有 IstioConduit

Istio 解析

Istio 是 Google 和 IBM 两位巨人联合 Lyft 的合作开源项目。是当前最主流的service mesh方案,也是事实上的第二代 service mesh 标准。

Google 和 IBM 之所以要带上小弟 Lyft 一起玩耍是因为他们不想从头开始做数据面的组件,于是在 Istio 中,直接把 Lyft 家的 Envoy 拿来做 sidecar. 除了sidecar, Istio中的控制面组件都是使用Go编写。Istio架构如下图所示:

对于一个仅提供服务与服务之间连接功能的基础设施来说,Istio的架构算不上简单。但是架构中的各个组件的理念的确非常先进和超前。

  • Envoy: 扮演sidecar的功能,协调服务网格中所有服务的出入站流量,并提供服务发现、负载均衡、限流熔断等能力,还可以收集大量与流量相关的性能指标。
  • Pilot: 负责部署在service mesh中的Envoy实例的生命周期管理。本质上是负责流量管理和控制,是将流量和基础设施扩展解耦,这是Istio的核心。感性上,可以把Pilot看做是管理sidecar的sidecar, 但是这个特殊的sidacar并不承载任何业务流量。Pilot让运维人员通过Pilot指定它们希望流量遵循什么规则,而不是哪些特定的pod/VM应该接收流量。有了 Pilot 这个组件,我们可以非常容易的实现 A/B 测试和金丝雀Canary测试:

  • Mixer: Mixer在应用程序代码和基础架构后端之间提供通用中介层。它的设计将策略决策移出应用层,用运维人员能够控制的配置取而代之。应用程序代码不再将应用程序代码与特定后端集成在一起,而是与Mixer进行相当简单的集成,然后Mixer负责与后端系统连接。也就是说,Mixer可以认为是其他后端基础设施(如数据库、监控、日志、配额等)的sidecar proxy:

  • Istio-Auth: 提供强大的服务间认证和终端用户认证,使用交互TLS,内置身份和证书管理。可以升级服务网格中的未加密流量,并为运维人员提供基于服务身份而不是网络控制来执行策略的能力。Istio的未来版本将增加细粒度的访问控制和审计,以使用各种访问控制机制(包括基于属性和角色的访问控制以及授权钩子)来控制和监视访问您的服务,API或资源的人员。

Istio 的很多设计理念的确非常吸引人,又有 Google 和 IBM 两个巨人加持,理论上这条赛道上的其他选手都可以直接退赛回家了。但是 Istio 发布的前几个版本都在可用性和易用性上都差强人意。此外,service mesh 布道师、 Linkerd 作者 William Morgan 也心有不甘。因此, William Morgan一方面在2017年7月11日,Linkerd 发布版本 1.1.1,宣布和 Istio 项目集成,一方面夜以继日的开发Conduit.

Conduit 解析

Conduit 各方面的设计理念与 Istio 非常类似。但是作者抛弃了 Linkerd, 使用Rust重新编写了sidecar, 叫做 Conduit Data Plane, 控制面则由Go编写的 Conduit Control Plane接管:

从Conduit的架构看,作者号称Conduit吸取了很多 Linkerd 的 Scala 的教训,比 Linkerd 更快,还轻,更简单,控制面功能更强可信度还是挺高的。与Istio比较,个人其实更喜欢Conduit的架构,一方面是它足够简单,另一方面对于要解决的问题足够聚焦。

nginMesh 凑热闹?

Service Mesh 最基础的功能毕竟是 sidecar proxy. 提到 proxy 怎么能够少了 nginx? 我想nginx自己也是这么想的吧😂 毫不意外,nginx也推出了其 service mesh 的开源实现:nginMesh.

不过,与 William Morgan 的死磕策略不同,nginMesh 从一开始就没有想过要做一套完整的第二代Service Mesh 开源方案,而是直接宣布兼容Istio, 作为Istio的 sidecar proxy. 由于 nginx 在反向代理方面广泛的使用,以及运维技术的相对成熟,nginMesh在sidecar proxy领域应该会有一席之地。

反思

对于大规模部署微服务(微服务数>1000)、内部服务异构程度高(交互协议/开发语言类型>5)的场景,使用service mesh是合适的。但是,可能大部分开发者面临的微服务和内部架构异构复杂度是没有这么高的。在这种情况下,使用service mesh就是一个case by case的问题了。

理论上,service mesh 实现了业务逻辑和控制的解耦。但是这并不是免费的。由于网络中多了一跳,增加了性能和延迟的开销。另一方面,由于每个服务都需要sidecar, 这会给本来就复杂的分布式系统更加复杂,尤其是在实施初期,运维对service mesh本身把控能力不足的情况下,往往会使整个系统更加难以管理。

本质上,service mesh 就是一个成规模的sidecar proxy集群。那么如果我们想渐进的改善我们的微服务架构的话,其实有针对性的部署配置gateway就可以了。该gateway的粒度可粗可细,粗可到整个api总入口,细可到每个服务实例。并且 Gateway 只负责进入的请求,不像 Sidecar 还需要负责对外的请求。因为 Gateway 可以把一组服务给聚合起来,所以服务对外的请求可以交给对方服务的 Gateway。于是,我们只需要用一个只负责进入请求的 Gateway 来简化需要同时负责进出请求的 Sidecar 的复杂度。

小结:service mesh不是银弹。对于大规模部署、异构复杂的微服务架构是不错的方案。对于中小规模的微服务架构,不妨尝试一下更简单可控的gateway, 在确定gateway已经无法解决当前问题后,再尝试渐进的完全service mesh化。

扩展阅读