link1st

link1st 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 91vh.com/ 编辑
编辑

分享互联网码农的一些趣事

个人动态

link1st 赞了回答 · 2020-02-21

有没有 golang fmt.Println 打印结构体信息字符串的 在线格式化工具?

go自带了几种格式化支持, 找个合适的就好

package main

import "fmt"
import "os"

type point struct {
    x, y int
}

func main() {

    // Go提供了几种打印格式,用来格式化一般的Go值,例如
    // 下面的%v打印了一个point结构体的对象的值
    p := point{1, 2}
    fmt.Printf("%v\n", p)

    // 如果所格式化的值是一个结构体对象,那么`%+v`的格式化输出
    // 将包括结构体的成员名称和值
    fmt.Printf("%+v\n", p)

    // `%#v`格式化输出将输出一个值的Go语法表示方式。
    fmt.Printf("%#v\n", p)

    // 使用`%T`来输出一个值的数据类型
    fmt.Printf("%T\n", p)

    // 格式化布尔型变量
    fmt.Printf("%t\n", true)

    // 有很多的方式可以格式化整型,使用`%d`是一种
    // 标准的以10进制来输出整型的方式
    fmt.Printf("%d\n", 123)

    // 这种方式输出整型的二进制表示方式
    fmt.Printf("%b\n", 14)

    // 这里打印出该整型数值所对应的字符
    fmt.Printf("%c\n", 33)

    // 使用`%x`输出一个值的16进制表示方式
    fmt.Printf("%x\n", 456)

    // 浮点型数值也有几种格式化方法。最基本的一种是`%f`
    fmt.Printf("%f\n", 78.9)

    // `%e`和`%E`使用科学计数法来输出整型
    fmt.Printf("%e\n", 123400000.0)
    fmt.Printf("%E\n", 123400000.0)

    // 使用`%s`输出基本的字符串
    fmt.Printf("%s\n", "\"string\"")

    // 输出像Go源码中那样带双引号的字符串,需使用`%q`
    fmt.Printf("%q\n", "\"string\"")

    // `%x`以16进制输出字符串,每个字符串的字节用两个字符输出
    fmt.Printf("%x\n", "hex this")

    // 使用`%p`输出一个指针的值
    fmt.Printf("%p\n", &p)

    // 当输出数字的时候,经常需要去控制输出的宽度和精度。
    // 可以使用一个位于%后面的数字来控制输出的宽度,默认
    // 情况下输出是右对齐的,左边加上空格
    fmt.Printf("|%6d|%6d|\n", 12, 345)

    // 你也可以指定浮点数的输出宽度,同时你还可以指定浮点数
    // 的输出精度
    fmt.Printf("|%6.2f|%6.2f|\n", 1.2, 3.45)

    // To left-justify, use the `-` flag.
    fmt.Printf("|%-6.2f|%-6.2f|\n", 1.2, 3.45)

    // 你也可以指定输出字符串的宽度来保证它们输出对齐。默认
    // 情况下,输出是右对齐的
    fmt.Printf("|%6s|%6s|\n", "foo", "b")

    // 为了使用左对齐你可以在宽度之前加上`-`号
    fmt.Printf("|%-6s|%-6s|\n", "foo", "b")

    // `Printf`函数的输出是输出到命令行`os.Stdout`的,你
    // 可以用`Sprintf`来将格式化后的字符串赋值给一个变量
    s := fmt.Sprintf("a %s", "string")
    fmt.Println(s)

    // 你也可以使用`Fprintf`来将格式化后的值输出到`io.Writers`
    fmt.Fprintf(os.Stderr, "an %s\n", "error")
}

结果:

{1 2}
{x:1 y:2}
main.point{x:1, y:2}
main.point
true
123
1110
!
1c8
78.900000
1.234000e+08
1.234000E+08
"string"
"\"string\""
6865782074686973
0x103a10c0
|    12|   345|
|  1.20|  3.45|
|1.20  |3.45  |
|   foo|     b|
|foo   |b     |
a string
an error

关注 7 回答 3

link1st 收藏了文章 · 2019-09-20

Go实现双向链表

本文介绍什么是链表,常见的链表有哪些,然后介绍链表这种数据结构会在哪些地方可以用到,以及 Redis 队列是底层的实现,通过一个小实例来演示 Redis 队列有哪些功能,最后通过 Go 实现一个双向链表。

链表

目录

  • 1、链表

    • 1.1 说明
    • 1.2 单向链表
    • 1.3 循环链表
    • 1.4 双向链表
  • 2、redis队列

    • 2.1 说明
    • 2.2 应用场景
    • 2.3 演示
  • 3、Go双向链表

    • 3.1 说明
    • 3.2 实现
  • 4、总结
  • 5、参考文献

1、链表

1.1 说明

链表

链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而顺序表相应的时间复杂度分别是O(logn)和O(1)。

链表有很多种不同的类型:单向链表,双向链表以及循环链表。

  • 优势:

可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。链表允许插入和移除表上任意位置上的节点。

  • 劣势:

由于链表增加了节点指针,空间开销比较大。链表一般查找数据的时候需要从第一个节点开始每次访问下一个节点,直到访问到需要的位置,查找数据比较慢。

  • 用途:

常用于组织检索较少,而删除、添加、遍历较多的数据。

如:文件系统、LRU cache、Redis 列表、内存管理等。

1.2 单向链表

链表中最简单的一种是单向链表,

一个单向链表的节点被分成两个部分。它包含两个域,一个信息域和一个指针域。第一个部分保存或者显示关于节点的信息,第二个部分存储下一个节点的地址,而最后一个节点则指向一个空值。单向链表只可向一个方向遍历。

单链表有一个头节点head,指向链表在内存的首地址。链表中的每一个节点的数据类型为结构体类型,节点有两个成员:整型成员(实际需要保存的数据)和指向下一个结构体类型节点的指针即下一个节点的地址(事实上,此单链表是用于存放整型数据的动态数组)。链表按此结构对各节点的访问需从链表的头找起,后续节点的地址由当前节点给出。无论在表中访问哪个节点,都需要从链表的头开始,顺序向后查找。链表的尾节点由于无后续节点,其指针域为空,写作为NULL。

1.3 循环链表

循环链表是与单向链表一样,是一种链式的存储结构,所不同的是,循环链表的最后一个结点的指针是指向该循环链表的第一个结点或者表头结点,从而构成一个环形的链。

循环链表的运算与单链表的运算基本一致。所不同的有以下几点:

1、在建立一个循环链表时,必须使其最后一个结点的指针指向表头结点,而不是像单链表那样置为NULL。

2、在判断是否到表尾时,是判断该结点链域的值是否是表头结点,当链域的值等于表头指针时,说明已到表尾。而非象单链表那样判断链域的值是否为NULL。

1.4 双向链表

双向链表

双向链表其实是单链表的改进,当我们对单链表进行操作时,有时你要对某个结点的直接前驱进行操作时,又必须从表头开始查找。这是由单链表结点的结构所限制的。因为单链表每个结点只有一个存储直接后继结点地址的链域,那么能不能定义一个既有存储直接后继结点地址的链域,又有存储直接前驱结点地址的链域的这样一个双链域结点结构呢?这就是双向链表。

在双向链表中,结点除含有数据域外,还有两个链域,一个存储直接后继结点地址,一般称之为右链域(当此“连接”为最后一个“连接”时,指向空值或者空列表);一个存储直接前驱结点地址,一般称之为左链域(当此“连接”为第一个“连接”时,指向空值或者空列表)。

2、redis队列

2.1 说明

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)

Redis 列表使用两种数据结构作为底层实现:双端列表(linkedlist)、压缩列表(ziplist)

通过配置文件中(list-max-ziplist-entries、list-max-ziplist-value)来选择是哪种实现方式

在数据量比较少的时候,使用双端链表和压缩列表性能差异不大,但是使用压缩列表更能节约内存空间

redis 链表的实现源码 redis src/adlist.h

2.2 应用场景

消息队列,秒杀项目

秒杀项目:

提前将需要的商品码信息存入 Redis 队列,在抢购的时候每个用户都从 Redis 队列中取商品码,由于 Redis 是单线程的,同时只能有一个商品码被取出,取到商品码的用户为购买成功,而且 Redis 性能比较高,能抗住较大的用户压力。

2.3 演示

如何通过 Redis 队列中防止并发情况下商品超卖的情况。

假设:

网站有三件商品需要卖,我们将数据存入 Redis 队列中

1、 将三个商品码(10001、10002、10003)存入 Redis 队列中

# 存入商品
RPUSH commodity:queue 10001 10002 10003

2、 存入以后,查询数据是否符合预期

# 查看全部元素
LRANGE commodity:queue 0 -1

# 查看队列的长度
LLEN commodity:queue

3、 抢购开始,获取商品码,抢到商品码的用户则可以购买(由于 Redis 是单线程的,同一个商品码只能被取一次
)

# 出队
LPOP commodity:queue

这里了解到 Redis 列表是怎么使用的,下面就用 Go 语言实现一个双向链表来实现这些功能。

3、Go双向链表

3.1 说明

这里只是用 Go 语言实现一个双向链表,实现:查询链表的长度、链表右端插入数据、左端取数据、取指定区间的节点等功能( 类似于 Redis 列表的中的 RPUSH、LRANGE、LPOP、LLEN功能 )。

3.2 实现

golang 双向链表

  • 节点定义

双向链表有两个指针,分别指向前一个节点和后一个节点

链表表头 prev 的指针为空,链表表尾 next 的指针为空

// 链表的一个节点
type ListNode struct {
    prev  *ListNode // 前一个节点
    next  *ListNode // 后一个节点
    value string    // 数据
}

// 创建一个节点
func NewListNode(value string) (listNode *ListNode) {
    listNode = &ListNode{
        value: value,
    }

    return
}

// 当前节点的前一个节点
func (n *ListNode) Prev() (prev *ListNode) {
    prev = n.prev

    return
}

// 当前节点的前一个节点
func (n *ListNode) Next() (next *ListNode) {
    next = n.next

    return
}

// 获取节点的值
func (n *ListNode) GetValue() (value string) {
    if n == nil {

        return
    }
    value = n.value

    return
}
  • 定义一个链表

链表为了方便操作,定义一个结构体,可以直接从表头、表尾进行访问,定义了一个属性 len ,直接可以返回链表的长度,直接查询链表的长度就不用遍历时间复杂度从 O(n) 到 O(1)。

// 链表
type List struct {
    head *ListNode // 表头节点
    tail *ListNode // 表尾节点
    len  int       // 链表的长度
}


// 创建一个空链表
func NewList() (list *List) {
    list = &List{
    }
    return
}

// 返回链表头节点
func (l *List) Head() (head *ListNode) {
    head = l.head

    return
}

// 返回链表尾节点
func (l *List) Tail() (tail *ListNode) {
    tail = l.tail

    return
}

// 返回链表长度
func (l *List) Len() (len int) {
    len = l.len

    return
}
  • 在链表的右边插入一个元素
// 在链表的右边插入一个元素
func (l *List) RPush(value string) {

    node := NewListNode(value)

    // 链表未空的时候
    if l.Len() == 0 {
        l.head = node
        l.tail = node
    } else {
        tail := l.tail
        tail.next = node
        node.prev = tail

        l.tail = node
    }

    l.len = l.len + 1

    return
}
  • 从链表左边取出一个节点
// 从链表左边取出一个节点
func (l *List) LPop() (node *ListNode) {

    // 数据为空
    if l.len == 0 {

        return
    }

    node = l.head

    if node.next == nil {
        // 链表未空
        l.head = nil
        l.tail = nil
    } else {
        l.head = node.next
    }
    l.len = l.len - 1

    return
}
  • 通过索引查找节点

通过索引查找节点,如果索引是负数则从表尾开始查找。

自然数和负数索引分别通过两种方式查找节点,找到指定索引或者是链表全部查找完则查找完成。

// 通过索引查找节点
// 查不到节点则返回空
func (l *List) Index(index int) (node *ListNode) {

    // 索引为负数则表尾开始查找
    if index < 0 {
        index = (-index) - 1
        node = l.tail
        for true {
            // 未找到
            if node == nil {

                return
            }

            // 查到数据
            if index == 0 {

                return
            }

            node = node.prev
            index--
        }
    } else {
        node = l.head
        for ; index > 0 && node != nil; index-- {
            node = node.next
        }
    }

    return
}
  • 返回指定区间的元素
// 返回指定区间的元素
func (l *List) Range(start, stop int) (nodes []*ListNode) {
    nodes = make([]*ListNode, 0)

    // 转为自然数
    if start < 0 {
        start = l.len + start
        if start < 0 {
            start = 0
        }
    }

    if stop < 0 {
        stop = l.len + stop
        if stop < 0 {
            stop = 0
        }
    }

    // 区间个数
    rangeLen := stop - start + 1
    if rangeLen < 0 {

        return
    }

    startNode := l.Index(start)
    for i := 0; i < rangeLen; i++ {
        if startNode == nil {
            break
        }

        nodes = append(nodes, startNode)
        startNode = startNode.next
    }

    return
}

4、总结

  • 到这里关于链表的使用已经结束,介绍链表是有哪些(单向链表,双向链表以及循环链表),也介绍了链表的应用场景(Redis 列表使用的是链表作为底层实现),最后用 Go 实现了双向链表,演示了链表在 Go 语言中是怎么使用的,大家可以在项目中更具实际的情况去使用。

5、参考文献

维基百科 链表

github redis

项目地址:go 实现队列

https://github.com/link1st/li...

查看原文

link1st 发布了文章 · 2019-09-20

Go实现双向链表

本文介绍什么是链表,常见的链表有哪些,然后介绍链表这种数据结构会在哪些地方可以用到,以及 Redis 队列是底层的实现,通过一个小实例来演示 Redis 队列有哪些功能,最后通过 Go 实现一个双向链表。

链表

目录

  • 1、链表

    • 1.1 说明
    • 1.2 单向链表
    • 1.3 循环链表
    • 1.4 双向链表
  • 2、redis队列

    • 2.1 说明
    • 2.2 应用场景
    • 2.3 演示
  • 3、Go双向链表

    • 3.1 说明
    • 3.2 实现
  • 4、总结
  • 5、参考文献

1、链表

1.1 说明

链表

链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而顺序表相应的时间复杂度分别是O(logn)和O(1)。

链表有很多种不同的类型:单向链表,双向链表以及循环链表。

  • 优势:

可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。链表允许插入和移除表上任意位置上的节点。

  • 劣势:

由于链表增加了节点指针,空间开销比较大。链表一般查找数据的时候需要从第一个节点开始每次访问下一个节点,直到访问到需要的位置,查找数据比较慢。

  • 用途:

常用于组织检索较少,而删除、添加、遍历较多的数据。

如:文件系统、LRU cache、Redis 列表、内存管理等。

1.2 单向链表

链表中最简单的一种是单向链表,

一个单向链表的节点被分成两个部分。它包含两个域,一个信息域和一个指针域。第一个部分保存或者显示关于节点的信息,第二个部分存储下一个节点的地址,而最后一个节点则指向一个空值。单向链表只可向一个方向遍历。

单链表有一个头节点head,指向链表在内存的首地址。链表中的每一个节点的数据类型为结构体类型,节点有两个成员:整型成员(实际需要保存的数据)和指向下一个结构体类型节点的指针即下一个节点的地址(事实上,此单链表是用于存放整型数据的动态数组)。链表按此结构对各节点的访问需从链表的头找起,后续节点的地址由当前节点给出。无论在表中访问哪个节点,都需要从链表的头开始,顺序向后查找。链表的尾节点由于无后续节点,其指针域为空,写作为NULL。

1.3 循环链表

循环链表是与单向链表一样,是一种链式的存储结构,所不同的是,循环链表的最后一个结点的指针是指向该循环链表的第一个结点或者表头结点,从而构成一个环形的链。

循环链表的运算与单链表的运算基本一致。所不同的有以下几点:

1、在建立一个循环链表时,必须使其最后一个结点的指针指向表头结点,而不是像单链表那样置为NULL。

2、在判断是否到表尾时,是判断该结点链域的值是否是表头结点,当链域的值等于表头指针时,说明已到表尾。而非象单链表那样判断链域的值是否为NULL。

1.4 双向链表

双向链表

双向链表其实是单链表的改进,当我们对单链表进行操作时,有时你要对某个结点的直接前驱进行操作时,又必须从表头开始查找。这是由单链表结点的结构所限制的。因为单链表每个结点只有一个存储直接后继结点地址的链域,那么能不能定义一个既有存储直接后继结点地址的链域,又有存储直接前驱结点地址的链域的这样一个双链域结点结构呢?这就是双向链表。

在双向链表中,结点除含有数据域外,还有两个链域,一个存储直接后继结点地址,一般称之为右链域(当此“连接”为最后一个“连接”时,指向空值或者空列表);一个存储直接前驱结点地址,一般称之为左链域(当此“连接”为第一个“连接”时,指向空值或者空列表)。

2、redis队列

2.1 说明

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)

Redis 列表使用两种数据结构作为底层实现:双端列表(linkedlist)、压缩列表(ziplist)

通过配置文件中(list-max-ziplist-entries、list-max-ziplist-value)来选择是哪种实现方式

在数据量比较少的时候,使用双端链表和压缩列表性能差异不大,但是使用压缩列表更能节约内存空间

redis 链表的实现源码 redis src/adlist.h

2.2 应用场景

消息队列,秒杀项目

秒杀项目:

提前将需要的商品码信息存入 Redis 队列,在抢购的时候每个用户都从 Redis 队列中取商品码,由于 Redis 是单线程的,同时只能有一个商品码被取出,取到商品码的用户为购买成功,而且 Redis 性能比较高,能抗住较大的用户压力。

2.3 演示

如何通过 Redis 队列中防止并发情况下商品超卖的情况。

假设:

网站有三件商品需要卖,我们将数据存入 Redis 队列中

1、 将三个商品码(10001、10002、10003)存入 Redis 队列中

# 存入商品
RPUSH commodity:queue 10001 10002 10003

2、 存入以后,查询数据是否符合预期

# 查看全部元素
LRANGE commodity:queue 0 -1

# 查看队列的长度
LLEN commodity:queue

3、 抢购开始,获取商品码,抢到商品码的用户则可以购买(由于 Redis 是单线程的,同一个商品码只能被取一次
)

# 出队
LPOP commodity:queue

这里了解到 Redis 列表是怎么使用的,下面就用 Go 语言实现一个双向链表来实现这些功能。

3、Go双向链表

3.1 说明

这里只是用 Go 语言实现一个双向链表,实现:查询链表的长度、链表右端插入数据、左端取数据、取指定区间的节点等功能( 类似于 Redis 列表的中的 RPUSH、LRANGE、LPOP、LLEN功能 )。

3.2 实现

golang 双向链表

  • 节点定义

双向链表有两个指针,分别指向前一个节点和后一个节点

链表表头 prev 的指针为空,链表表尾 next 的指针为空

// 链表的一个节点
type ListNode struct {
    prev  *ListNode // 前一个节点
    next  *ListNode // 后一个节点
    value string    // 数据
}

// 创建一个节点
func NewListNode(value string) (listNode *ListNode) {
    listNode = &ListNode{
        value: value,
    }

    return
}

// 当前节点的前一个节点
func (n *ListNode) Prev() (prev *ListNode) {
    prev = n.prev

    return
}

// 当前节点的前一个节点
func (n *ListNode) Next() (next *ListNode) {
    next = n.next

    return
}

// 获取节点的值
func (n *ListNode) GetValue() (value string) {
    if n == nil {

        return
    }
    value = n.value

    return
}
  • 定义一个链表

链表为了方便操作,定义一个结构体,可以直接从表头、表尾进行访问,定义了一个属性 len ,直接可以返回链表的长度,直接查询链表的长度就不用遍历时间复杂度从 O(n) 到 O(1)。

// 链表
type List struct {
    head *ListNode // 表头节点
    tail *ListNode // 表尾节点
    len  int       // 链表的长度
}


// 创建一个空链表
func NewList() (list *List) {
    list = &List{
    }
    return
}

// 返回链表头节点
func (l *List) Head() (head *ListNode) {
    head = l.head

    return
}

// 返回链表尾节点
func (l *List) Tail() (tail *ListNode) {
    tail = l.tail

    return
}

// 返回链表长度
func (l *List) Len() (len int) {
    len = l.len

    return
}
  • 在链表的右边插入一个元素
// 在链表的右边插入一个元素
func (l *List) RPush(value string) {

    node := NewListNode(value)

    // 链表未空的时候
    if l.Len() == 0 {
        l.head = node
        l.tail = node
    } else {
        tail := l.tail
        tail.next = node
        node.prev = tail

        l.tail = node
    }

    l.len = l.len + 1

    return
}
  • 从链表左边取出一个节点
// 从链表左边取出一个节点
func (l *List) LPop() (node *ListNode) {

    // 数据为空
    if l.len == 0 {

        return
    }

    node = l.head

    if node.next == nil {
        // 链表未空
        l.head = nil
        l.tail = nil
    } else {
        l.head = node.next
    }
    l.len = l.len - 1

    return
}
  • 通过索引查找节点

通过索引查找节点,如果索引是负数则从表尾开始查找。

自然数和负数索引分别通过两种方式查找节点,找到指定索引或者是链表全部查找完则查找完成。

// 通过索引查找节点
// 查不到节点则返回空
func (l *List) Index(index int) (node *ListNode) {

    // 索引为负数则表尾开始查找
    if index < 0 {
        index = (-index) - 1
        node = l.tail
        for true {
            // 未找到
            if node == nil {

                return
            }

            // 查到数据
            if index == 0 {

                return
            }

            node = node.prev
            index--
        }
    } else {
        node = l.head
        for ; index > 0 && node != nil; index-- {
            node = node.next
        }
    }

    return
}
  • 返回指定区间的元素
// 返回指定区间的元素
func (l *List) Range(start, stop int) (nodes []*ListNode) {
    nodes = make([]*ListNode, 0)

    // 转为自然数
    if start < 0 {
        start = l.len + start
        if start < 0 {
            start = 0
        }
    }

    if stop < 0 {
        stop = l.len + stop
        if stop < 0 {
            stop = 0
        }
    }

    // 区间个数
    rangeLen := stop - start + 1
    if rangeLen < 0 {

        return
    }

    startNode := l.Index(start)
    for i := 0; i < rangeLen; i++ {
        if startNode == nil {
            break
        }

        nodes = append(nodes, startNode)
        startNode = startNode.next
    }

    return
}

4、总结

  • 到这里关于链表的使用已经结束,介绍链表是有哪些(单向链表,双向链表以及循环链表),也介绍了链表的应用场景(Redis 列表使用的是链表作为底层实现),最后用 Go 实现了双向链表,演示了链表在 Go 语言中是怎么使用的,大家可以在项目中更具实际的情况去使用。

5、参考文献

维基百科 链表

github redis

项目地址:go 实现队列

https://github.com/link1st/li...

查看原文

赞 8 收藏 6 评论 0

link1st 收藏了文章 · 2019-08-29

基于websocket单台机器支持百万连接分布式聊天(IM)系统

基于websocket单台机器支持百万连接分布式聊天(IM)系统

本文将介绍如何实现一个基于websocket分布式聊天(IM)系统。

使用golang实现websocket通讯,单机可以支持百万连接,使用gin框架、nginx负载、可以水平部署、程序内部相互通讯、使用grpc通讯协议。

本文内容比较长,如果直接想clone项目体验直接进入项目体验goWebSocket项目下载 ,文本从介绍webSocket是什么开始,然后开始介绍这个项目,以及在Nginx中配置域名做webSocket的转发,然后介绍如何搭建一个分布式系统。

目录

1、项目说明

1.1 goWebSocket

本文将介绍如何实现一个基于websocket聊天(IM)分布式系统。

使用golang实现websocket通讯,单机支持百万连接,使用gin框架、nginx负载、可以水平部署、程序内部相互通讯、使用grpc通讯协议。

  • 一般项目中webSocket使用的架构图

网站架构图

1.2 项目体验

2、介绍webSocket

2.1 webSocket 是什么

WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

  • HTTP和WebSocket在通讯过程的比较

HTTP协议和WebSocket比较

  • HTTP和webSocket都支持配置证书,ws:// 无证书 wss:// 配置证书的协议标识

HTTP协议和WebSocket比较

2.2 webSocket的兼容性

  • 浏览器的兼容性,开始支持webSocket的版本

浏览器开始支持webSocket的版本

  • 服务端的支持

golang、java、php、node.js、python、nginx 都有不错的支持

  • Android和IOS的支持

Android可以使用java-webSocket对webSocket支持

iOS 4.2及更高版本具有WebSockets支持

2.3 为什么要用webSocket

    1. 从业务上出发,需要一个主动通达客户端的能力
目前大多数的请求都是使用HTTP,都是由客户端发起一个请求,有服务端处理,然后返回结果,不可以服务端主动向某一个客户端主动发送数据

服务端处理一个请求

    1. 大多数场景我们需要主动通知用户,如:聊天系统、用户完成任务主动告诉用户、一些运营活动需要通知到在线的用户
    1. 可以获取用户在线状态
    1. 在没有长链接的时候通过客户端主动轮询获取数据
    1. 可以通过一种方式实现,多种不同平台(H5/Android/IOS)去使用

2.4 webSocket建立过程

    1. 客户端先发起升级协议的请求

客户端发起升级协议的请求,采用标准的HTTP报文格式,在报文中添加头部信息

Connection: Upgrade表明连接需要升级

Upgrade: websocket需要升级到 websocket协议

Sec-WebSocket-Version: 13 协议的版本为13

Sec-WebSocket-Key: I6qjdEaqYljv3+9x+GrhqA== 这个是base64 encode 的值,是浏览器随机生成的,与服务器响应的 Sec-WebSocket-Accept对应

# Request Headers
Connection: Upgrade
Host: im.91vh.com
Origin: http://im.91vh.com
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: I6qjdEaqYljv3+9x+GrhqA==
Sec-WebSocket-Version: 13
Upgrade: websocket

浏览器 Network

    1. 服务器响应升级协议

服务端接收到升级协议的请求,如果服务端支持升级协议会做如下响应

返回:

Status Code: 101 Switching Protocols 表示支持切换协议

# Response Headers
Connection: upgrade
Date: Fri, 09 Aug 2019 07:36:59 GMT
Sec-WebSocket-Accept: mB5emvxi2jwTUhDdlRtADuBax9E=
Server: nginx/1.12.1
Upgrade: websocket
    1. 升级协议完成以后,客户端和服务器就可以相互发送数据

websocket接收和发送数据

3、如何实现基于webSocket的长链接系统

3.1 使用go实现webSocket服务端

3.1.1 启动端口监听

  • websocket需要监听端口,所以需要在golang 成功的 main 函数中用协程的方式去启动程序
  • main.go 实现启动
go websocket.StartWebSocket()
  • init_acc.go 启动程序
// 启动程序
func StartWebSocket() {
    http.HandleFunc("/acc", wsPage)
    http.ListenAndServe(":8089", nil)
}

3.1.2 升级协议

  • 客户端是通过http请求发送到服务端,我们需要对http协议进行升级为websocket协议
  • 对http请求协议进行升级 golang 库gorilla/websocket 已经做得很好了,我们直接使用就可以了
  • 在实际使用的时候,建议每个连接使用两个协程处理客户端请求数据和向客户端发送数据,虽然开启协程会占用一些内存,但是读取分离,减少收发数据堵塞的可能
  • init_acc.go
func wsPage(w http.ResponseWriter, req *http.Request) {

    // 升级协议
    conn, err := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
        fmt.Println("升级协议", "ua:", r.Header["User-Agent"], "referer:", r.Header["Referer"])

        return true
    }}).Upgrade(w, req, nil)
    if err != nil {
        http.NotFound(w, req)

        return
    }

    fmt.Println("webSocket 建立连接:", conn.RemoteAddr().String())

    currentTime := uint64(time.Now().Unix())
    client := NewClient(conn.RemoteAddr().String(), conn, currentTime)

    go client.read()
    go client.write()

    // 用户连接事件
    clientManager.Register <- client
}

3.1.3 客户端连接的管理

  • 当前程序有多少用户连接,还需要对用户广播的需要,这里我们就需要一个管理者(clientManager),处理这些事件:
  • 记录全部的连接、登录用户的可以通过 appId+uuid 查到用户连接
  • 使用map存储,就涉及到多协程并发读写的问题,所以需要加读写锁
  • 定义四个channel ,分别处理客户端建立连接、用户登录、断开连接、全员广播事件
// 连接管理
type ClientManager struct {
    Clients     map[*Client]bool   // 全部的连接
    ClientsLock sync.RWMutex       // 读写锁
    Users       map[string]*Client // 登录的用户 // appId+uuid
    UserLock    sync.RWMutex       // 读写锁
    Register    chan *Client       // 连接连接处理
    Login       chan *login        // 用户登录处理
    Unregister  chan *Client       // 断开连接处理程序
    Broadcast   chan []byte        // 广播 向全部成员发送数据
}

// 初始化
func NewClientManager() (clientManager *ClientManager) {
    clientManager = &ClientManager{
        Clients:    make(map[*Client]bool),
        Users:      make(map[string]*Client),
        Register:   make(chan *Client, 1000),
        Login:      make(chan *login, 1000),
        Unregister: make(chan *Client, 1000),
        Broadcast:  make(chan []byte, 1000),
    }

    return
}

3.1.4 注册客户端的socket的写的异步处理程序

  • 防止发生程序崩溃,所以需要捕获异常
  • 为了显示异常崩溃位置这里使用string(debug.Stack())打印调用堆栈信息
  • 如果写入数据失败了,可能连接有问题,就关闭连接
  • client.go
// 向客户端写数据
func (c *Client) write() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("write stop", string(debug.Stack()), r)

        }
    }()

    defer func() {
        clientManager.Unregister <- c
        c.Socket.Close()
        fmt.Println("Client发送数据 defer", c)
    }()

    for {
        select {
        case message, ok := <-c.Send:
            if !ok {
                // 发送数据错误 关闭连接
                fmt.Println("Client发送数据 关闭连接", c.Addr, "ok", ok)

                return
            }

            c.Socket.WriteMessage(websocket.TextMessage, message)
        }
    }
}

3.1.5 注册客户端的socket的读的异步处理程序

  • 循环读取客户端发送的数据并处理
  • 如果读取数据失败了,关闭channel
  • client.go
// 读取客户端数据
func (c *Client) read() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("write stop", string(debug.Stack()), r)
        }
    }()

    defer func() {
        fmt.Println("读取客户端数据 关闭send", c)
        close(c.Send)
    }()

    for {
        _, message, err := c.Socket.ReadMessage()
        if err != nil {
            fmt.Println("读取客户端数据 错误", c.Addr, err)

            return
        }

        // 处理程序
        fmt.Println("读取客户端数据 处理:", string(message))
        ProcessData(c, message)
    }
}

3.1.6 接收客户端数据并处理

  • 约定发送和接收请求数据格式,为了js处理方便,采用了json的数据格式发送和接收数据(人类可以阅读的格式在工作开发中使用是比较方便的)
  • 登录发送数据示例:
{"seq":"1565336219141-266129","cmd":"login","data":{"userId":"马远","appId":101}}
  • 登录响应数据示例:
{"seq":"1565336219141-266129","cmd":"login","response":{"code":200,"codeMsg":"Success","data":null}}
  • websocket是双向的数据通讯,可以连续发送,如果发送的数据需要服务端回复,就需要一个seq来确定服务端的响应是回复哪一次的请求数据
  • cmd 是用来确定动作,websocket没有类似于http的url,所以规定 cmd 是什么动作
  • 目前的动作有:login/heartbeat 用来发送登录请求和连接保活(长时间没有数据发送的长连接容易被浏览器、移动中间商、nginx、服务端程序断开)
  • 为什么需要AppId,UserId是表示用户的唯一字段,设计的时候为了做成通用性,设计AppId用来表示用户在哪个平台登录的(web、app、ios等),方便后续扩展
  • request_model.go 约定的请求数据格式
/************************  请求数据  **************************/
// 通用请求数据格式
type Request struct {
    Seq  string      `json:"seq"`            // 消息的唯一Id
    Cmd  string      `json:"cmd"`            // 请求命令字
    Data interface{} `json:"data,omitempty"` // 数据 json
}

// 登录请求数据
type Login struct {
    ServiceToken string `json:"serviceToken"` // 验证用户是否登录
    AppId        uint32 `json:"appId,omitempty"`
    UserId       string `json:"userId,omitempty"`
}

// 心跳请求数据
type HeartBeat struct {
    UserId string `json:"userId,omitempty"`
}
  • response_model.go
/************************  响应数据  **************************/
type Head struct {
    Seq      string    `json:"seq"`      // 消息的Id
    Cmd      string    `json:"cmd"`      // 消息的cmd 动作
    Response *Response `json:"response"` // 消息体
}

type Response struct {
    Code    uint32      `json:"code"`
    CodeMsg string      `json:"codeMsg"`
    Data    interface{} `json:"data"` // 数据 json
}

3.1.7 使用路由的方式处理客户端的请求数据

  • 使用路由的方式处理由客户端发送过来的请求数据
  • 以后添加请求类型以后就可以用类是用http相类似的方式(router-controller)去处理
  • acc_routers.go
// Websocket 路由
func WebsocketInit() {
    websocket.Register("login", websocket.LoginController)
    websocket.Register("heartbeat", websocket.HeartbeatController)
}

3.1.8 防止内存溢出和Goroutine不回收

    1. 定时任务清除超时连接

没有登录的连接和登录的连接6分钟没有心跳则断开连接

client_manager.go

// 定时清理超时连接
func ClearTimeoutConnections() {
    currentTime := uint64(time.Now().Unix())

    for client := range clientManager.Clients {
        if client.IsHeartbeatTimeout(currentTime) {
            fmt.Println("心跳时间超时 关闭连接", client.Addr, client.UserId, client.LoginTime, client.HeartbeatTime)

            client.Socket.Close()
        }
    }
}
    1. 读写的Goroutine有一个失败,则相互关闭

write()Goroutine写入数据失败,关闭c.Socket.Close()连接,会关闭read()Goroutine
read()Goroutine读取数据失败,关闭close(c.Send)连接,会关闭write()Goroutine

    1. 客户端主动关闭

关闭读写的Goroutine
ClientManager删除连接

    1. 监控用户连接、Goroutine数

十个内存溢出有九个和Goroutine有关
添加一个http的接口,可以查看系统的状态,防止Goroutine不回收
查看系统状态

    1. Nginx 配置不活跃的连接释放时间,防止忘记关闭的连接
    1. 使用 pprof 分析性能、耗时

3.2 使用javaScript实现webSocket客户端

3.2.1 启动并注册监听程序

  • js 建立连接,并处理连接成功、收到数据、断开连接的事件处理
ws = new WebSocket("ws://127.0.0.1:8089/acc");

 
ws.onopen = function(evt) {
  console.log("Connection open ...");
};
 
ws.onmessage = function(evt) {
  console.log( "Received Message: " + evt.data);
  data_array = JSON.parse(evt.data);
  console.log( data_array);
};
 
ws.onclose = function(evt) {
  console.log("Connection closed.");
};

3.2.2 发送数据

  • 需要注意:连接建立成功以后才可以发送数据
  • 建立连接以后由客户端向服务器发送数据示例
登录:
ws.send('{"seq":"2323","cmd":"login","data":{"userId":"11","appId":101}}');

心跳:
ws.send('{"seq":"2324","cmd":"heartbeat","data":{}}');
 
关闭连接:
ws.close();

4、goWebSocket 项目

4.1 项目说明

  • 本项目是基于webSocket实现的分布式IM系统
  • 客户端随机分配用户名,所有人进入一个聊天室,实现群聊的功能
  • 单台机器(24核128G内存)支持百万客户端连接
  • 支持水平部署,部署的机器之间可以相互通讯
  • 项目架构图

网站架构图

4.2 项目依赖

  • 本项目只需要使用 redis 和 golang
  • 本项目使用govendor管理依赖,克隆本项目就可以直接使用
# 主要使用到的包
github.com/gin-gonic/gin@v1.4.0
github.com/go-redis/redis
github.com/gorilla/websocket
github.com/spf13/viper
google.golang.org/grpc
github.com/golang/protobuf

4.3 项目启动

  • 克隆项目
git clone git@github.com:link1st/gowebsocket.git
# 或
git clone https://github.com/link1st/gowebsocket.git
  • 修改项目配置
cd gowebsocket
cd config
mv app.yaml.example app.yaml
# 修改项目监听端口,redis连接等(默认127.0.0.1:3306)
vim app.yaml
# 返回项目目录,为以后启动做准备
cd ..
  • 配置文件说明
app:
  logFile: log/gin.log # 日志文件位置
  httpPort: 8080 # http端口
  webSocketPort: 8089 # webSocket端口
  rpcPort: 9001 # 分布式部署程序内部通讯端口
  httpUrl: 127.0.0.1:8080
  webSocketUrl:  127.0.0.1:8089


redis:
  addr: "localhost:6379"
  password: ""
  DB: 0
  poolSize: 30
  minIdleConns: 30
  • 启动项目
go run main.go
  • 进入IM聊天地址

http://127.0.0.1:8080/home/index

  • 到这里,就可以体验到基于webSocket的IM系统

5、webSocket项目Nginx配置

5.1 为什么要配置Nginx

  • 使用nginx实现内外网分离,对外只暴露Nginx的Ip(一般的互联网企业会在nginx之前加一层LVS做负载均衡),减少入侵的可能
  • 使用Nginx可以利用Nginx的负载功能,前端再使用的时候只需要连接固定的域名,通过Nginx将流量分发了到不同的机器
  • 同时我们也可以使用Nginx的不同的负载策略(轮询、weight、ip_hash)

5.2 nginx配置

  • 使用域名 im.91vh.com 为示例,参考配置
  • 一级目录im.91vh.com/acc 是给webSocket使用,是用nginx stream转发功能(nginx 1.3.31 开始支持,使用Tengine配置也是相同的),转发到golang 8089 端口处理
  • 其它目录是给HTTP使用,转发到golang 8080 端口处理
upstream  go-im
{
    server 127.0.0.1:8080 weight=1 max_fails=2 fail_timeout=10s;
    keepalive 16;
}

upstream  go-acc
{
    server 127.0.0.1:8089 weight=1 max_fails=2 fail_timeout=10s;
    keepalive 16;
}


server {
    listen       80 ;
    server_name  im.91vh.com;
    index index.html index.htm ;


    location /acc {
        proxy_set_header Host $host;
        proxy_pass http://go-acc;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Connection "";
        proxy_redirect off;
        proxy_intercept_errors on;
        client_max_body_size 10m;
    }

    location /
    {
        proxy_set_header Host $host;
        proxy_pass http://go-im;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_redirect off;
        proxy_intercept_errors on;
        client_max_body_size 30m;
    }

    access_log  /link/log/nginx/access/im.log;
    error_log   /link/log/nginx/access/im.error.log;
}

5.3 问题处理

  • 运行nginx测试命令,查看配置文件是否正确
/link/server/tengine/sbin/nginx -t
  • 如果出现错误
nginx: [emerg] unknown "connection_upgrade" variable
configuration file /link/server/tengine/conf/nginx.conf test failed
  • 处理方法
  • nginx.com添加
http{
    fastcgi_temp_file_write_size 128k;
..... # 需要添加的内容

    #support websocket
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

.....
    gzip on;
    
}
  • 原因:Nginx代理webSocket的时候就会遇到Nginx的设计问题 End-to-end and Hop-by-hop Headers

6、压测

6.1 Linux内核优化

  • 设置文件打开句柄数
ulimit -n 1000000
  • 设置sockets连接参数
vim /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 0

6.2 压测准备

  • 待压测,如果大家有压测的结果欢迎补充

6.3 压测数据

  • 项目在实际使用的时候,每个连接约占 24Kb内存,一个Goroutine 约占11kb
  • 支持百万连接需要22G内存
在线用户数cup内存I/Onet.out
1W
10W
100W

7、如何基于webSocket实现一个分布式Im

7.1 说明

获取全部在线的用户,查询单前服务的全部用户+集群中服务的全部用户
发送消息,这里采用的是http接口发送(微信网页版发送消息也是http接口),这里考虑主要是两点:
1.服务分离,让acc系统尽量的简单一点,不掺杂其它业务逻辑
2.发送消息是走http接口,不使用webSocket连接,才用收和发送数据分离的方式,可以加快收发数据的效率

7.2 架构

  • 项目启动注册和用户连接时序图

用户连接时序图

  • 其它系统(IM、任务)向webSocket(acc)系统连接的用户发送消息时序图

分布是系统随机给用户发送消息

8、回顾和反思

8.1 在其它系统应用

  • 本系统设计的初衷就是:和客户端保持一个长链接、对外部系统两个接口(查询用户是否在线、给在线的用户推送消息),实现业务的分离
  • 只有和业务分离可,才可以供多个业务使用,而不是每个业务都建立一个长链接

8.2 已经实现的功能

  • gin log日志(请求日志+debug日志)
  • 读取配置文件 完成
  • 定时脚本,清理过期未心跳链接 完成
  • http接口,获取登录、链接数量 完成
  • http接口,发送push、查询有多少人在线 完成
  • grpc 程序内部通讯,发送消息 完成
  • appIds 一个用户在多个平台登录
  • 界面,把所有在线的人拉倒一个群里面,发送消息 完成
  • 单聊、群聊 完成
  • 实现分布式,水平扩张 完成
  • 压测脚本
  • 文档整理
  • 文档目录、百万长链接的实现、为什么要实现一个IM、怎么实现一个Im
  • 架构图以及扩展

IM实现细节:

  • 定义文本消息结构 完成
  • html发送文本消息 完成
  • 接口接收文本消息并发送给全体 完成
  • html接收到消息 显示到界面 完成
  • 界面优化 需要持续优化
  • 有人加入以后广播全体 完成
  • 定义加入聊天室的消息结构 完成
  • 引入机器人 待定

8.2 需要完善、优化

  • 登录,使用微信登录 获取昵称、头像等
  • 有账号系统、资料系统
  • 界面优化、适配手机端
  • 消息 文本消息(支持表情)、图片、语音、视频消息
  • 微服务注册、发现、熔断等
  • 添加配置项,单台机器最大连接数量

8.3 总结

  • 虽然实现了一个分布式在聊天的IM,但是有很多细节没有处理(登录没有鉴权、界面还待优化等),但是可以通过这个示例可以了解到:通过WebSocket解决很多业务上需求
  • 本文虽然号称单台机器能有百万长链接(内存上能满足),但是实际在场景远比这个复杂(cpu有些压力),当然了如果你有这么大的业务量可以购买更多的机器更好的去支撑你的业务,本程序只是演示如何在实际工作用使用webSocket.
  • 参考本文,你可以实现出来符合你需要的程序

9、参考文献

维基百科 WebSocket

阮一峰 WebSocket教程

WebSocket协议:5分钟从入门到精通

link1st gowebsocket

查看原文

link1st 收藏了文章 · 2019-08-28

压测工具如何选择? ab、locust、Jmeter、go压测工具【单台机器100w连接压测实战】

本文介绍压测是什么,解释压测的专属名词,教大家如何压测。介绍市面上的常见压测工具(ab、locust、Jmeter、go实现的压测工具、云压测),对比这些压测工具,教大家如何选择一款适合自己的压测工具,本文还有两个压测实战项目:

  • 单台机器对HTTP短连接 QPS 1W+ 的压测实战
  • 单台机器100W长连接的压测实战

目录

  • 1、项目说明

    • 1.1 go-stress-testing
    • 1.2 项目体验
  • 2、压测

    • 2.1 压测是什么
    • 2.2 为什么要压测
    • 2.3 压测名词解释

      • 2.3.1 压测类型解释
      • 2.3.2 压测名词解释
      • 2.3.3 机器性能指标解释
      • 2.3.4 访问指标解释
    • 3.4 如何计算压测指标
  • 3、常见的压测工具

    • 3.1 ab
    • 3.2 locust
    • 3.3 Jmeter
    • 3.4 云压测

      • 3.4.1 云压测介绍
      • 3.4.2 阿里云 性能测试 PTS
      • 3.4.3 腾讯云 压测大师 LM
  • 4、go-stress-testing go语言实现的压测工具

    • 4.1 介绍
    • 4.2 用法
    • 4.3 实现
    • 4.4 go-stress-testing 对 Golang web 压测
  • 5、压测工具的比较

    • 5.1 比较
    • 5.2 如何选择压测工具
  • 6、单台机器100w连接压测实战

    • 6.1 说明
    • 6.2 内核优化
    • 6.3 客户端配置
    • 6.4 准备
    • 6.5 压测数据
  • 7、总结
  • 8、参考文献

1、项目说明

1.1 go-stress-testing

go 实现的压测工具,每个用户用一个协程的方式模拟,最大限度的利用CPU资源

1.2 项目体验

  • 可以在 mac/linux/windows 不同平台下执行的命令

参数说明:

-c 表示并发数

-n 每个并发执行请求的次数,总请求的次数 = 并发数 * 每个并发执行请求的次数

-u 需要压测的地址


# clone 项目
git clone https://github.com/link1st/go-stress-testing.git

# 进入项目目录
cd go-stress-testing

# 运行 
go run main.go -c 1 -n 100 -u https://www.baidu.com/

  • 压测结果展示

执行以后,终端每秒钟都会输出一次结果,压测完成以后输出执行的压测结果

压测结果展示:


─────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────
 耗时│ 并发数 │ 成功数│ 失败数 │   qps  │最长耗时 │最短耗时│平均耗时 │ 错误码
─────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────
   1s│      1│      8│      0│    8.09│  133.16│  110.98│  123.56│200:8
   2s│      1│     15│      0│    8.02│  138.74│  110.98│  124.61│200:15
   3s│      1│     23│      0│    7.80│  220.43│  110.98│  128.18│200:23
   4s│      1│     31│      0│    7.83│  220.43│  110.23│  127.67│200:31
   5s│      1│     39│      0│    7.81│  220.43│  110.23│  128.03│200:39
   6s│      1│     46│      0│    7.72│  220.43│  110.23│  129.59│200:46
   7s│      1│     54│      0│    7.79│  220.43│  110.23│  128.42│200:54
   8s│      1│     62│      0│    7.81│  220.43│  110.23│  128.09│200:62
   9s│      1│     70│      0│    7.79│  220.43│  110.23│  128.33│200:70
  10s│      1│     78│      0│    7.82│  220.43│  106.47│  127.85│200:78
  11s│      1│     84│      0│    7.64│  371.02│  106.47│  130.96│200:84
  12s│      1│     91│      0│    7.63│  371.02│  106.47│  131.02│200:91
  13s│      1│     99│      0│    7.66│  371.02│  106.47│  130.54│200:99
  13s│      1│    100│      0│    7.66│  371.02│  106.47│  130.52│200:100


*************************  结果 stat  ****************************
处理协程数量: 1
请求总数: 100 总请求时间: 13.055 秒 successNum: 100 failureNum: 0
*************************  结果 end   ****************************

参数解释:

耗时: 程序运行耗时。程序每秒钟输出一次压测结果

并发数: 并发数,启动的协程数

成功数: 压测中,请求成功的数量

失败数: 压测中,请求失败的数量

qps: 当前压测的QPS(每秒钟处理请求数量)

最长耗时: 压测中,单个请求最长的响应时长

最短耗时: 压测中,单个请求最短的响应时长

平均耗时: 压测中,单个请求平均的响应时长

错误码: 压测中,接口返回的 code码:返回次数的集合

2、压测

2.1 压测是什么

压测,即压力测试,是确立系统稳定性的一种测试方法,通常在系统正常运作范围之外进行,以考察其功能极限和隐患。

主要检测服务器的承受能力,包括用户承受能力(多少用户同时玩基本不影响质量)、流量承受等。

2.2 为什么要压测

  • 压测的目的就是通过压测(模拟真实用户的行为),测算出机器的性能(单台机器的QPS),从而推算出系统在承受指定用户数(100W)时,需要多少机器能支撑得住
  • 压测是在上线前为了应对未来可能达到的用户数量的一次预估(提前演练),压测以后通过优化程序的性能或准备充足的机器,来保证用户的体验。

2.3 压测名词解释

2.3.1 压测类型解释

压测类型解释
压力测试(Stress Testing)也称之为强度测试,测试一个系统的最大抗压能力,在强负载(大数据、高并发)的情况下,测试系统所能承受的最大压力,预估系统的瓶颈
并发测试(Concurrency Testing)通过模拟很多用户同一时刻访问系统或对系统某一个功能进行操作,来测试系统的性能,从中发现问题(并发读写、线程控制、资源争抢)
耐久性测试(Configuration Testing)通过对系统在大负荷的条件下长时间运行,测试系统、机器的长时间运行下的状况,从中发现问题(内存泄漏、数据库连接池不释放、资源不回收)

2.3.2 压测名词解释

压测名词解释
并发(Concurrency)指一个处理器同时处理多个任务的能力(逻辑上处理的能力)
并行(Parallel)多个处理器或者是多核的处理器同时处理多个不同的任务(物理上同时执行)
QPS(每秒钟查询数量 Query Per Second)服务器每秒钟处理请求数量 (req/sec 请求数/秒 一段时间内总请求数/请求时间)
事务(Transactions)是用户一次或者是几次请求的集合
TPS(每秒钟处理事务数量 Transaction Per Second)服务器每秒钟处理事务数量(一个事务可能包括多个请求)
请求成功数(Request Success Number)在一次压测中,请求成功的数量
请求失败数(Request Failures Number)在一次压测中,请求失败的数量
错误率(Error Rate)在压测中,请求成功的数量与请求失败数量的比率
最大响应时间(Max Response Time)在一次事务中,从发出请求或指令系统做出的反映(响应)的最大时间
最少响应时间(Mininum Response Time)在一次事务中,从发出请求或指令系统做出的反映(响应)的最少时间
平均响应时间(Average Response Time)在一次事务中,从发出请求或指令系统做出的反映(响应)的平均时间

2.3.3 机器性能指标解释

机器性能解释
CUP利用率(CPU Usage)CUP 利用率分用户态、系统态和空闲态,CPU利用率是指:CPU执行非系统空闲进程的时间与CPU总执行时间的比率
内存使用率(Memory usage)内存使用率指的是此进程所开销的内存。
IO(Disk input/ output)磁盘的读写包速率
网卡负载(Network Load)网卡的进出带宽,包量

2.3.4 访问指标解释

访问解释
PV(页面浏览量 Page View)用户每打开1个网站页面,记录1个PV。用户多次打开同一页面,PV值累计多次
UV(网站独立访客 Unique Visitor)通过互联网访问、流量网站的自然人。1天内相同访客多次访问网站,只计算为1个独立访客

2.4 如何计算压测指标

  • 压测我们需要有目的性的压测,这次压测我们需要达到什么目标(如:单台机器的性能为100QPS?网站能同时满足100W人同时在线)
  • 可以通过以下计算方法来进行计算:
  • 压测原则:每天80%的访问量集中在20%的时间里,这20%的时间就叫做峰值
  • 公式: ( 总PV数80% ) / ( 每天的秒数20% ) = 峰值时间每秒钟请求数(QPS)
  • 机器: 峰值时间每秒钟请求数(QPS) / 单台机器的QPS = 需要的机器的数量
  • 假设:网站每天的用户数(100W),每天的用户的访问量约为3000W PV,这台机器的需要多少QPS?
( 30000000*0.8 ) / (86400 * 0.2) ≈ 1389 (QPS)
  • 假设:单台机器的的QPS是69,需要需要多少台机器来支撑?
1389 / 69 ≈ 20

3、常见的压测工具

3.1 ab

  • 简介

ApacheBench 是 Apache服务器自带的一个web压力测试工具,简称ab。ab又是一个命令行工具,对发起负载的本机要求很低,根据ab命令可以创建很多的并发访问线程,模拟多个访问者同时对某一URL地址进行访问,因此可以用来测试目标服务器的负载压力。总的来说ab工具小巧简单,上手学习较快,可以提供需要的基本性能指标,但是没有图形化结果,不能监控。

ab属于一个轻量级的压测工具,结果不会特别准确,可以用作参考。

  • 安装
# 在linux环境安装
sudo yum -y install httpd
  • 用法
Usage: ab [options] [http[s]://]hostname[:port]/path
用法:ab [选项] 地址

选项:
Options are:
    -n requests      #执行的请求数,即一共发起多少请求。
    -c concurrency   #请求并发数。
    -s timeout       #指定每个请求的超时时间,默认是30秒。
    -k               #启用HTTP KeepAlive功能,即在一个HTTP会话中执行多个请求。默认时,不启用KeepAlive功能。
  • 压测命令
# 使用ab压测工具,对百度的链接 请求100次,并发数1
ab -n 100 -c 1 https://www.baidu.com/

压测结果

~ >ab -n 100 -c 1 https://www.baidu.com/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking www.baidu.com (be patient).....done


Server Software:        BWS/1.1
Server Hostname:        www.baidu.com
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES128-GCM-SHA256,2048,128

Document Path:          /
Document Length:        227 bytes

Concurrency Level:      1
Time taken for tests:   9.430 seconds
Complete requests:      100
Failed requests:        0
Write errors:           0
Total transferred:      89300 bytes
HTML transferred:       22700 bytes
Requests per second:    10.60 [#/sec] (mean)
Time per request:       94.301 [ms] (mean)
Time per request:       94.301 [ms] (mean, across all concurrent requests)
Transfer rate:          9.25 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       54   70  16.5     69     180
Processing:    18   24  12.0     23     140
Waiting:       18   24  12.0     23     139
Total:         72   94  20.5     93     203

Percentage of the requests served within a certain time (ms)
  50%     93
  66%     99
  75%    101
  80%    102
  90%    108
  95%    122
  98%    196
  99%    203
 100%    203 (longest request)
  • 主要关注的测试指标
  • Concurrency Level 并发请求数
  • Time taken for tests 整个测试时间
  • Complete requests 完成请求个数
  • Failed requests 失败个数
  • Requests per second 吞吐量,指的是某个并发用户下单位时间内处理的请求数。等效于QPS,其实可以看作同一个统计方式,只是叫法不同而已。
  • Time per request 用户平均请求等待时间
  • Time per request 服务器处理时间

3.2 Locust

  • 简介

是非常简单易用、分布式、python开发的压力测试工具。有图形化界面,支持将压测数据导出。

  • 安装
# pip3 安装locust
pip3  install locust
# 查看是否安装成功
locust -h
# 运行 Locust 分布在多个进程/机器库
pip3 install pyzmq
# webSocket 压测库
pip3 install websocket-client
  • 用法

编写压测脚本 test.py

from locust import HttpLocust, TaskSet, task

# 定义用户行为
class UserBehavior(TaskSet):

    @task
    def baidu_index(self):
        self.client.get("/")


class WebsiteUser(HttpLocust):
    task_set = UserBehavior # 指向一个定义的用户行为类
    min_wait = 3000 # 执行事务之间用户等待时间的下界(单位:毫秒)
    max_wait = 6000 # 执行事务之间用户等待时间的上界(单位:毫秒)
  • 启动压测
locust -f  test.py --host=https://www.baidu.com

访问 http://localhost:8089 进入压测首页

Number of users to simulate 模拟用户数

Hatch rate (users spawned/second) 每秒钟增加用户数

点击 "Start swarming" 进入压测页面

locust 首页

压测界面右上角有:被压测的地址、当前状态、RPS、失败率、开始或重启按钮

性能测试参数

  • Type 请求的类型,例如GET/POST
  • Name 请求的路径
  • Request 当前请求的数量
  • Fails 当前请求失败的数量
  • Median 中间值,单位毫秒,请求响应时间的中间值
  • Average 平均值,单位毫秒,请求的平均响应时间
  • Min 请求的最小服务器响应时间,单位毫秒
  • Max 请求的最大服务器响应时间,单位毫秒
  • Average size 单个请求的大小,单位字节
  • Current RPS 代表吞吐量(Requests Per Second的缩写),指的是某个并发用户数下单位时间内处理的请求数。等效于QPS,其实可以看作同一个统计方式,只是叫法不同而已。

locust 压测页面

3.3 Jmeter

  • 简介

Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。
JMeter能够对应用程序做功能/回归测试,通过创建带有断言的脚本来验证你的程序返回了你期望的结果。

  • 安装

访问 https://jmeter-plugins.org/in... 下载解压以后即可使用

  • 用法

JMeter的功能过于强大,这里暂时不介绍用法,可以查询相关文档使用(参考文献中有推荐的教程文档)

3.4 云压测

3.4.1 云压测介绍

顾名思义就是将压测脚本部署在云端,通过云端对对我们的应用进行全方位压测,只需要配置压测的参数,无需准备实体机,云端自动给我们分配需要压测的云主机,对被压测目标进行压测。

云压测的优势:

  1. 轻易的实现分布式部署
  2. 能够模拟海量用户的访问
  3. 流量可以从全国各地发起,更加真实的反映用户的体验
  4. 全方位的监控压测指标
  5. 文档比较完善

当然了云压测是一款商业产品,在使用的时候自然还是需要收费的,而且价格还是比较昂贵的~

3.4.2 阿里云 性能测试 PTS

PTS(Performance Testing Service)是面向所有技术背景人员的云化测试工具。有别于传统工具的繁复,PTS以互联网化的交互,提供性能测试、API调试和监测等多种能力。自研和适配开源的功能都可以轻松模拟任意体量的用户访问业务的场景,任务随时发起,免去繁琐的搭建和维护成本。更是紧密结合监控、流控等兄弟产品提供一站式高可用能力,高效检验和管理业务性能。

阿里云同样还是支持渗透测试,通过模拟黑客对业务系统进行全面深入的安全测试。

3.4.3 腾讯云 压测大师 LM

通过创建虚拟机器人模拟多用户的并发场景,提供一整套完整的服务器压测解决方案

4、go-stress-testing go语言实现的压测工具

4.1 介绍

  • go-stress-testing 是go语言实现的简单压测工具,源码开源、支持二次开发,可以压测http、webSocket请求,使用协程模拟单个用户,可以更高效的利用CPU资源。
  • 项目地址 https://github.com/link1st/go-stress-testing

4.2 用法

  • 支持参数:
Usage of ./go_stress_testing_mac:
  -c uint
        并发数 (default 1)
  -d string
        调试模式 (default "false")
  -n uint
        请求总数 (default 1)
  -p string
        curl文件路径
  -u string
        请求地址
  -v string
        验证方法 http 支持:statusCode、json webSocket支持:json (default "statusCode")
  • -n 是单个用户请求的次数,请求总次数 = -c* -n, 这里考虑的是模拟用户行为,所以这个是每个用户请求的次数
  • 使用示例:
# 查看用法
go run main.go

# 使用请求百度页面
go run main.go -c 1 -n 100 -u https://www.baidu.com/

# 使用debug模式请求百度页面
go run main.go -c 1 -n 1 -d true -u https://www.baidu.com/

# 使用 curl文件(文件在curl目录下) 的方式请求
go run main.go -c 1 -n 1 -p curl/baidu.curl.txt

# 压测webSocket连接
go run main.go -c 10 -n 10 -u ws://127.0.0.1:8089/acc
  • 使用 curl文件进行压测

curl是Linux在命令行下的工作的文件传输工具,是一款很强大的http命令行工具。

使用curl文件可以压测使用非GET的请求,支持设置http请求的 method、cookies、header、body等参数

chrome 浏览器生成 curl文件,打开开发者模式(快捷键F12),如图所示,生成 curl 在终端执行命令
copy cURL

生成内容粘贴到项目目录下的curl/baidu.curl.txt文件中,执行下面命令就可以从curl.txt文件中读取需要压测的内容进行压测了

# 使用 curl文件(文件在curl目录下) 的方式请求
go run main.go -c 1 -n 1 -p curl/baidu.curl.txt

4.3 实现

  • 具体需求可以查看项目源码
  • 项目目录结构
|____main.go                      // main函数,获取命令行参数
|____server                       // 处理程序目录
| |____dispose.go                 // 压测启动,注册验证器、启动统计函数、启动协程进行压测
| |____statistics                 // 统计目录
| | |____statistics.go            // 接收压测统计结果并处理
| |____golink                     // 建立连接目录
| | |____http_link.go             // http建立连接
| | |____websocket_link.go        // webSocket建立连接
| |____client                     // 请求数据客户端目录
| | |____http_client.go           // http客户端
| | |____websocket_client.go      // webSocket客户端
| |____verify                     // 对返回数据校验目录
| | |____http_verify.go           // http返回数据校验
| | |____websokcet_verify.go      // webSocket返回数据校验
|____heper                        // 通用函数目录
| |____heper.go                   // 通用函数
|____model                        // 模型目录
| |____request_model.go           // 请求数据模型
| |____curl_model.go              // curl文件解析
|____vendor                       // 项目依赖目录

4.4 go-stress-testing 对 Golang web 压测

这里使用go-stress-testing对go server进行压测(部署在同一台机器上),并统计压测结果

  • 申请的服务器配置

CPU: 4核 (Intel Xeon(Cascade Lake) Platinum 8269 2.5 GHz/3.2 GHz)

内存: 16G
硬盘: 20G SSD
系统: CentOS 7.6

go version: go1.12.9 linux/amd64

go-stress-testing01

  • go server
package main

import (
    "log"
    "net/http"
)

const (
    httpPort = "8088"
)

func main() {

    runtime.GOMAXPROCS(runtime.NumCPU() - 1)

    hello := func(w http.ResponseWriter, req *http.Request) {
        data := "Hello, World!"

        w.Header().Add("Server", "golang")
        w.Write([]byte(data))

        return
    }

    http.HandleFunc("/", hello)
    err := http.ListenAndServe(":"+httpPort, nil)

    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}
  • go_stress_testing 压测命令
./go_stress_testing_linux -c 100 -n 10000 -u http://127.0.0.1:8088/
  • 压测结果
并发数go_stress_testing QPS
16394.86
416909.36
1018456.81
2019490.50
3019947.47
5019922.56
8019155.33
10018336.46
20016813.86

从压测的结果上看:效果还不错,压测QPS有接近2W

5、压测工具的比较

5.1 比较

-ablocustJmetergo-stress-testing云压测
实现语言CPythonJavaGolang-
UI界面
优势使用简单,上手简单支持分布式、压测数据支持导出插件丰富,支持生成HTML报告项目开源,使用简单,没有依赖,支持webSocket压测更加真实的模拟用户,支持更高的压测力度

5.2 如何选择压测工具

这个世界上没有最好的,只有最适合的,工具千千万,选择一款适合你的才是最重要的

在实际使用中有各种场景,选择工具的时候就需要考虑这些:

  • 明确你的目的,需要做什么压测、压测的目标是什么?
  • 使用的工具你是否熟悉,你愿意花多大的成本了解它?
  • 你是为了测试还是想了解其中的原理?
  • 工具是否能支持你需要压测的场景

6、单台机器100w连接压测实战

6.1 说明

之前写了一篇文章,基于websocket单台机器支持百万连接分布式聊天(IM)系统(不了解这个项目可以查看上一篇或搜索一下文章),这里我们要实现单台机器支持100W连接的压测

目标:

  • 单台机器能保持100W个长连接
  • 机器的CPU、内存、网络、I/O 状态都正常

说明:

gowebsocket 分布式聊天(IM)系统:

  • 之前用户连接以后有个全员广播,这里需要将用户连接、退出等事件关闭
  • 服务器准备:
由于自己手上没有自己的服务器,所以需要临时购买的云服务器

压测服务器:

16台(稍后解释为什么需要16台机器)

CPU: 2核
内存: 8G
硬盘: 20G
系统: CentOS 7.6

webSocket压测服务器

被压测服务:

1台

CPU: 4核
内存: 32G
硬盘: 20G SSD
系统: CentOS 7.6

webSocket被压测服务器

6.2 内核优化

  • 修改程序最大打开文件数

被压测服务器需要保持100W长连接,客户和服务器端是通过socket通讯的,每个连接需要建立一个socket,程序需要保持100W长连接就需要单个程序能打开100W个文件句柄

# 查看系统默认的值
ulimit -n
# 设置最大打开文件数
ulimit -n 1040000

这里设置的要超过100W,程序除了有100W连接还有其它资源连接(数据库、资源等连接),这里设置为 104W

centOS 7.6 上述设置不生效,需要手动修改配置文件

vim /etc/security/limits.conf

这里需要把硬限制和软限制、root用户和所有用户都设置为 1040000

core 是限制内核文件的大小,这里设置为 unlimited

# 添加一下参数
root soft nofile 1040000
root hard nofile 1040000

root soft nofile 1040000
root hard nproc 1040000

root soft core unlimited
root hard core unlimited

* soft nofile 1040000
* hard nofile 1040000

* soft nofile 1040000
* hard nproc 1040000

* soft core unlimited
* hard core unlimited

注意:

/proc/sys/fs/file-max 表示系统级别的能够打开的文件句柄的数量,不能小于limits中设置的值

如果file-max的值小于limits设置的值会导致系统重启以后无法登录

# file-max 设置的值参考
cat /proc/sys/fs/file-max
12553500

修改以后重启服务器,ulimit -n 查看配置是否生效

6.3 客户端配置

由于linux端口的范围是 0~65535(2^16-1)这个和操作系统无关,不管linux是32位的还是64位的

这个数字是由于tcp协议决定的,tcp协议头部表示端口只有16位,所以最大值只有65535(如果每台机器多几个虚拟ip就能突破这个限制)

1024以下是系统保留端口,所以能使用的1024到65535

如果需要100W长连接,每台机器有 65535-1024 个端口, 100W / (65535-1024) ≈ 15.5,所以这里需要16台服务器

  • vim /etc/sysctl.conf 在文件末尾添加
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_mem = 786432 2097152 3145728
net.ipv4.tcp_rmem = 4096 4096 16777216
net.ipv4.tcp_wmem = 4096 4096 16777216

配置解释:

  • ip_local_port_range 表示TCP/UDP协议允许使用的本地端口号 范围:1024~65000
  • tcp_mem 确定TCP栈应该如何反映内存使用,每个值的单位都是内存页(通常是4KB)。第一个值是内存使用的下限;第二个值是内存压力模式开始对缓冲区使用应用压力的上限;第三个值是内存使用的上限。在这个层次上可以将报文丢弃,从而减少对内存的使用。对于较大的BDP可以增大这些值(注意,其单位是内存页而不是字节)
  • tcp_rmem 为自动调优定义socket使用的内存。第一个值是为socket接收缓冲区分配的最少字节数;第二个值是默认值(该值会被rmem_default覆盖),缓冲区在系统负载不重的情况下可以增长到这个值;第三个值是接收缓冲区空间的最大字节数(该值会被rmem_max覆盖)。
  • tcp_wmem 为自动调优定义socket使用的内存。第一个值是为socket发送缓冲区分配的最少字节数;第二个值是默认值(该值会被wmem_default覆盖),缓冲区在系统负载不重的情况下可以增长到这个值;第三个值是发送缓冲区空间的最大字节数(该值会被wmem_max覆盖)。

6.4 准备

  1. 在被压测服务器上启动Server服务(gowebsocket)
  2. 查看被压测服务器的内网端口
  3. 登录上16台压测服务器,这里我提前把需要优化的系统做成了镜像,申请机器的时候就可以直接使用这个镜像(参数已经调好)

压测服务器16台准备

  1. 启动压测
 ./go_stress_testing_linux -c 62500 -n 1  -u ws://192.168.0.74:443/acc

62500*16 = 100W 正好可以达到我们的要求

建立连接以后,-n 1发送一个ping的消息给服务器,收到响应以后保持连接不中断

  1. 通过 gowebsocket服务器的http接口,实时查询连接数和项目启动的协程数
  2. 压测过程中查看系统状态
# linux 命令
ps      # 查看进程内存、cup使用情况
iostat  # 查看系统IO情况
nload   # 查看网络流量情况
/proc/pid/status # 查看进程状态

6.5 压测数据

  • 压测以后,查看连接数到100W,然后保持10分钟观察系统是否正常
  • 观察以后,系统运行正常、CPU、内存、I/O 都正常,打开页面都正常
  • 压测完成以后的数据

查看goWebSocket连接数统计,可以看到 clientsLen连接数为100W,goroutine数量2000008个,每个连接两个goroutine加上项目启动默认的8个。这里可以看到连接数满足了100W

查看goWebSocket连接数统计

从压测服务上查看连接数是否达到了要求,压测完成的统计数据并发数为62500,是每个客户端连接的数量,总连接数: 62500*16=100W

压测服务16台 压测完成

  • 记录内存使用情况,分别记录了1W到100W连接数内存使用情况
连接数内存
10000281M
1000002.7g
2000005.4g
50000013.1g
100000025.8g

100W连接时的查看内存详细数据:

cat /proc/pid/status
VmSize: 27133804 kB

27133804/1000000≈27.1 100W连接,占用了25.8g的内存,粗略计算了一下,一个连接占用了27.1Kb的内存,由于goWebSocket项目每个用户连接起了两个协程处理用户的读写事件,所以内存占用稍微多一点

如果需要如何减少内存使用可以参考 @Roy11568780 大佬给的解决方案

传统的golang中是采用的一个goroutine循环read的方法对应每一个socket。实际百万链路场景中这是巨大的资源浪费,优化的原理也不是什么新东西,golang中一样也可以使用epoll的,把fd拿到epoll中,检测到事件然后在协程池里面去读就行了,看情况读写分别10-20的协程goroutine池应该就足够了

至此,压测已经全部完成,单台机器支持100W连接已经满足~

7、总结

到这里压测总算完成,本次压测花费16元巨款。

单台机器支持100W连接是实测是满足的,但是实际业务比较复杂,还是需要持续优化~

通过实现介绍什么是压测,在什么情况下需要压测,如果觉得现有的压测工具不适用,可以自己实现或者是改造成适合自己的工具。

8、参考文献

性能测试工具

性能测试常见名词解释

性能测试名词解释

PV、TPS、QPS是怎么计算出来的?

超实用压力测试工具-ab工具

Locust 介绍

Jmeter性能测试 入门

基于websocket单台机器支持百万连接分布式聊天(IM)系统

github 搜:link1st 查看项目 go-stress-testing

https://github.com/link1st/go-stress-testing

查看原文

link1st 发布了文章 · 2019-08-28

压测工具如何选择? ab、locust、Jmeter、go压测工具【单台机器100w连接压测实战】

本文介绍压测是什么,解释压测的专属名词,教大家如何压测。介绍市面上的常见压测工具(ab、locust、Jmeter、go实现的压测工具、云压测),对比这些压测工具,教大家如何选择一款适合自己的压测工具,本文还有两个压测实战项目:

  • 单台机器对HTTP短连接 QPS 1W+ 的压测实战
  • 单台机器100W长连接的压测实战

目录

  • 1、项目说明

    • 1.1 go-stress-testing
    • 1.2 项目体验
  • 2、压测

    • 2.1 压测是什么
    • 2.2 为什么要压测
    • 2.3 压测名词解释

      • 2.3.1 压测类型解释
      • 2.3.2 压测名词解释
      • 2.3.3 机器性能指标解释
      • 2.3.4 访问指标解释
    • 3.4 如何计算压测指标
  • 3、常见的压测工具

    • 3.1 ab
    • 3.2 locust
    • 3.3 Jmeter
    • 3.4 云压测

      • 3.4.1 云压测介绍
      • 3.4.2 阿里云 性能测试 PTS
      • 3.4.3 腾讯云 压测大师 LM
  • 4、go-stress-testing go语言实现的压测工具

    • 4.1 介绍
    • 4.2 用法
    • 4.3 实现
    • 4.4 go-stress-testing 对 Golang web 压测
  • 5、压测工具的比较

    • 5.1 比较
    • 5.2 如何选择压测工具
  • 6、单台机器100w连接压测实战

    • 6.1 说明
    • 6.2 内核优化
    • 6.3 客户端配置
    • 6.4 准备
    • 6.5 压测数据
  • 7、总结
  • 8、参考文献

1、项目说明

1.1 go-stress-testing

go 实现的压测工具,每个用户用一个协程的方式模拟,最大限度的利用CPU资源

1.2 项目体验

  • 可以在 mac/linux/windows 不同平台下执行的命令

参数说明:

-c 表示并发数

-n 每个并发执行请求的次数,总请求的次数 = 并发数 * 每个并发执行请求的次数

-u 需要压测的地址


# clone 项目
git clone https://github.com/link1st/go-stress-testing.git

# 进入项目目录
cd go-stress-testing

# 运行 
go run main.go -c 1 -n 100 -u https://www.baidu.com/

  • 压测结果展示

执行以后,终端每秒钟都会输出一次结果,压测完成以后输出执行的压测结果

压测结果展示:


─────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────
 耗时│ 并发数 │ 成功数│ 失败数 │   qps  │最长耗时 │最短耗时│平均耗时 │ 错误码
─────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────
   1s│      1│      8│      0│    8.09│  133.16│  110.98│  123.56│200:8
   2s│      1│     15│      0│    8.02│  138.74│  110.98│  124.61│200:15
   3s│      1│     23│      0│    7.80│  220.43│  110.98│  128.18│200:23
   4s│      1│     31│      0│    7.83│  220.43│  110.23│  127.67│200:31
   5s│      1│     39│      0│    7.81│  220.43│  110.23│  128.03│200:39
   6s│      1│     46│      0│    7.72│  220.43│  110.23│  129.59│200:46
   7s│      1│     54│      0│    7.79│  220.43│  110.23│  128.42│200:54
   8s│      1│     62│      0│    7.81│  220.43│  110.23│  128.09│200:62
   9s│      1│     70│      0│    7.79│  220.43│  110.23│  128.33│200:70
  10s│      1│     78│      0│    7.82│  220.43│  106.47│  127.85│200:78
  11s│      1│     84│      0│    7.64│  371.02│  106.47│  130.96│200:84
  12s│      1│     91│      0│    7.63│  371.02│  106.47│  131.02│200:91
  13s│      1│     99│      0│    7.66│  371.02│  106.47│  130.54│200:99
  13s│      1│    100│      0│    7.66│  371.02│  106.47│  130.52│200:100


*************************  结果 stat  ****************************
处理协程数量: 1
请求总数: 100 总请求时间: 13.055 秒 successNum: 100 failureNum: 0
*************************  结果 end   ****************************

参数解释:

耗时: 程序运行耗时。程序每秒钟输出一次压测结果

并发数: 并发数,启动的协程数

成功数: 压测中,请求成功的数量

失败数: 压测中,请求失败的数量

qps: 当前压测的QPS(每秒钟处理请求数量)

最长耗时: 压测中,单个请求最长的响应时长

最短耗时: 压测中,单个请求最短的响应时长

平均耗时: 压测中,单个请求平均的响应时长

错误码: 压测中,接口返回的 code码:返回次数的集合

2、压测

2.1 压测是什么

压测,即压力测试,是确立系统稳定性的一种测试方法,通常在系统正常运作范围之外进行,以考察其功能极限和隐患。

主要检测服务器的承受能力,包括用户承受能力(多少用户同时玩基本不影响质量)、流量承受等。

2.2 为什么要压测

  • 压测的目的就是通过压测(模拟真实用户的行为),测算出机器的性能(单台机器的QPS),从而推算出系统在承受指定用户数(100W)时,需要多少机器能支撑得住
  • 压测是在上线前为了应对未来可能达到的用户数量的一次预估(提前演练),压测以后通过优化程序的性能或准备充足的机器,来保证用户的体验。

2.3 压测名词解释

2.3.1 压测类型解释

压测类型解释
压力测试(Stress Testing)也称之为强度测试,测试一个系统的最大抗压能力,在强负载(大数据、高并发)的情况下,测试系统所能承受的最大压力,预估系统的瓶颈
并发测试(Concurrency Testing)通过模拟很多用户同一时刻访问系统或对系统某一个功能进行操作,来测试系统的性能,从中发现问题(并发读写、线程控制、资源争抢)
耐久性测试(Configuration Testing)通过对系统在大负荷的条件下长时间运行,测试系统、机器的长时间运行下的状况,从中发现问题(内存泄漏、数据库连接池不释放、资源不回收)

2.3.2 压测名词解释

压测名词解释
并发(Concurrency)指一个处理器同时处理多个任务的能力(逻辑上处理的能力)
并行(Parallel)多个处理器或者是多核的处理器同时处理多个不同的任务(物理上同时执行)
QPS(每秒钟查询数量 Query Per Second)服务器每秒钟处理请求数量 (req/sec 请求数/秒 一段时间内总请求数/请求时间)
事务(Transactions)是用户一次或者是几次请求的集合
TPS(每秒钟处理事务数量 Transaction Per Second)服务器每秒钟处理事务数量(一个事务可能包括多个请求)
请求成功数(Request Success Number)在一次压测中,请求成功的数量
请求失败数(Request Failures Number)在一次压测中,请求失败的数量
错误率(Error Rate)在压测中,请求成功的数量与请求失败数量的比率
最大响应时间(Max Response Time)在一次事务中,从发出请求或指令系统做出的反映(响应)的最大时间
最少响应时间(Mininum Response Time)在一次事务中,从发出请求或指令系统做出的反映(响应)的最少时间
平均响应时间(Average Response Time)在一次事务中,从发出请求或指令系统做出的反映(响应)的平均时间

2.3.3 机器性能指标解释

机器性能解释
CUP利用率(CPU Usage)CUP 利用率分用户态、系统态和空闲态,CPU利用率是指:CPU执行非系统空闲进程的时间与CPU总执行时间的比率
内存使用率(Memory usage)内存使用率指的是此进程所开销的内存。
IO(Disk input/ output)磁盘的读写包速率
网卡负载(Network Load)网卡的进出带宽,包量

2.3.4 访问指标解释

访问解释
PV(页面浏览量 Page View)用户每打开1个网站页面,记录1个PV。用户多次打开同一页面,PV值累计多次
UV(网站独立访客 Unique Visitor)通过互联网访问、流量网站的自然人。1天内相同访客多次访问网站,只计算为1个独立访客

2.4 如何计算压测指标

  • 压测我们需要有目的性的压测,这次压测我们需要达到什么目标(如:单台机器的性能为100QPS?网站能同时满足100W人同时在线)
  • 可以通过以下计算方法来进行计算:
  • 压测原则:每天80%的访问量集中在20%的时间里,这20%的时间就叫做峰值
  • 公式: ( 总PV数80% ) / ( 每天的秒数20% ) = 峰值时间每秒钟请求数(QPS)
  • 机器: 峰值时间每秒钟请求数(QPS) / 单台机器的QPS = 需要的机器的数量
  • 假设:网站每天的用户数(100W),每天的用户的访问量约为3000W PV,这台机器的需要多少QPS?
( 30000000*0.8 ) / (86400 * 0.2) ≈ 1389 (QPS)
  • 假设:单台机器的的QPS是69,需要需要多少台机器来支撑?
1389 / 69 ≈ 20

3、常见的压测工具

3.1 ab

  • 简介

ApacheBench 是 Apache服务器自带的一个web压力测试工具,简称ab。ab又是一个命令行工具,对发起负载的本机要求很低,根据ab命令可以创建很多的并发访问线程,模拟多个访问者同时对某一URL地址进行访问,因此可以用来测试目标服务器的负载压力。总的来说ab工具小巧简单,上手学习较快,可以提供需要的基本性能指标,但是没有图形化结果,不能监控。

ab属于一个轻量级的压测工具,结果不会特别准确,可以用作参考。

  • 安装
# 在linux环境安装
sudo yum -y install httpd
  • 用法
Usage: ab [options] [http[s]://]hostname[:port]/path
用法:ab [选项] 地址

选项:
Options are:
    -n requests      #执行的请求数,即一共发起多少请求。
    -c concurrency   #请求并发数。
    -s timeout       #指定每个请求的超时时间,默认是30秒。
    -k               #启用HTTP KeepAlive功能,即在一个HTTP会话中执行多个请求。默认时,不启用KeepAlive功能。
  • 压测命令
# 使用ab压测工具,对百度的链接 请求100次,并发数1
ab -n 100 -c 1 https://www.baidu.com/

压测结果

~ >ab -n 100 -c 1 https://www.baidu.com/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking www.baidu.com (be patient).....done


Server Software:        BWS/1.1
Server Hostname:        www.baidu.com
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES128-GCM-SHA256,2048,128

Document Path:          /
Document Length:        227 bytes

Concurrency Level:      1
Time taken for tests:   9.430 seconds
Complete requests:      100
Failed requests:        0
Write errors:           0
Total transferred:      89300 bytes
HTML transferred:       22700 bytes
Requests per second:    10.60 [#/sec] (mean)
Time per request:       94.301 [ms] (mean)
Time per request:       94.301 [ms] (mean, across all concurrent requests)
Transfer rate:          9.25 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       54   70  16.5     69     180
Processing:    18   24  12.0     23     140
Waiting:       18   24  12.0     23     139
Total:         72   94  20.5     93     203

Percentage of the requests served within a certain time (ms)
  50%     93
  66%     99
  75%    101
  80%    102
  90%    108
  95%    122
  98%    196
  99%    203
 100%    203 (longest request)
  • 主要关注的测试指标
  • Concurrency Level 并发请求数
  • Time taken for tests 整个测试时间
  • Complete requests 完成请求个数
  • Failed requests 失败个数
  • Requests per second 吞吐量,指的是某个并发用户下单位时间内处理的请求数。等效于QPS,其实可以看作同一个统计方式,只是叫法不同而已。
  • Time per request 用户平均请求等待时间
  • Time per request 服务器处理时间

3.2 Locust

  • 简介

是非常简单易用、分布式、python开发的压力测试工具。有图形化界面,支持将压测数据导出。

  • 安装
# pip3 安装locust
pip3  install locust
# 查看是否安装成功
locust -h
# 运行 Locust 分布在多个进程/机器库
pip3 install pyzmq
# webSocket 压测库
pip3 install websocket-client
  • 用法

编写压测脚本 test.py

from locust import HttpLocust, TaskSet, task

# 定义用户行为
class UserBehavior(TaskSet):

    @task
    def baidu_index(self):
        self.client.get("/")


class WebsiteUser(HttpLocust):
    task_set = UserBehavior # 指向一个定义的用户行为类
    min_wait = 3000 # 执行事务之间用户等待时间的下界(单位:毫秒)
    max_wait = 6000 # 执行事务之间用户等待时间的上界(单位:毫秒)
  • 启动压测
locust -f  test.py --host=https://www.baidu.com

访问 http://localhost:8089 进入压测首页

Number of users to simulate 模拟用户数

Hatch rate (users spawned/second) 每秒钟增加用户数

点击 "Start swarming" 进入压测页面

locust 首页

压测界面右上角有:被压测的地址、当前状态、RPS、失败率、开始或重启按钮

性能测试参数

  • Type 请求的类型,例如GET/POST
  • Name 请求的路径
  • Request 当前请求的数量
  • Fails 当前请求失败的数量
  • Median 中间值,单位毫秒,请求响应时间的中间值
  • Average 平均值,单位毫秒,请求的平均响应时间
  • Min 请求的最小服务器响应时间,单位毫秒
  • Max 请求的最大服务器响应时间,单位毫秒
  • Average size 单个请求的大小,单位字节
  • Current RPS 代表吞吐量(Requests Per Second的缩写),指的是某个并发用户数下单位时间内处理的请求数。等效于QPS,其实可以看作同一个统计方式,只是叫法不同而已。

locust 压测页面

3.3 Jmeter

  • 简介

Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。
JMeter能够对应用程序做功能/回归测试,通过创建带有断言的脚本来验证你的程序返回了你期望的结果。

  • 安装

访问 https://jmeter-plugins.org/in... 下载解压以后即可使用

  • 用法

JMeter的功能过于强大,这里暂时不介绍用法,可以查询相关文档使用(参考文献中有推荐的教程文档)

3.4 云压测

3.4.1 云压测介绍

顾名思义就是将压测脚本部署在云端,通过云端对对我们的应用进行全方位压测,只需要配置压测的参数,无需准备实体机,云端自动给我们分配需要压测的云主机,对被压测目标进行压测。

云压测的优势:

  1. 轻易的实现分布式部署
  2. 能够模拟海量用户的访问
  3. 流量可以从全国各地发起,更加真实的反映用户的体验
  4. 全方位的监控压测指标
  5. 文档比较完善

当然了云压测是一款商业产品,在使用的时候自然还是需要收费的,而且价格还是比较昂贵的~

3.4.2 阿里云 性能测试 PTS

PTS(Performance Testing Service)是面向所有技术背景人员的云化测试工具。有别于传统工具的繁复,PTS以互联网化的交互,提供性能测试、API调试和监测等多种能力。自研和适配开源的功能都可以轻松模拟任意体量的用户访问业务的场景,任务随时发起,免去繁琐的搭建和维护成本。更是紧密结合监控、流控等兄弟产品提供一站式高可用能力,高效检验和管理业务性能。

阿里云同样还是支持渗透测试,通过模拟黑客对业务系统进行全面深入的安全测试。

3.4.3 腾讯云 压测大师 LM

通过创建虚拟机器人模拟多用户的并发场景,提供一整套完整的服务器压测解决方案

4、go-stress-testing go语言实现的压测工具

4.1 介绍

  • go-stress-testing 是go语言实现的简单压测工具,源码开源、支持二次开发,可以压测http、webSocket请求,使用协程模拟单个用户,可以更高效的利用CPU资源。
  • 项目地址 https://github.com/link1st/go-stress-testing

4.2 用法

  • 支持参数:
Usage of ./go_stress_testing_mac:
  -c uint
        并发数 (default 1)
  -d string
        调试模式 (default "false")
  -n uint
        请求总数 (default 1)
  -p string
        curl文件路径
  -u string
        请求地址
  -v string
        验证方法 http 支持:statusCode、json webSocket支持:json (default "statusCode")
  • -n 是单个用户请求的次数,请求总次数 = -c* -n, 这里考虑的是模拟用户行为,所以这个是每个用户请求的次数
  • 使用示例:
# 查看用法
go run main.go

# 使用请求百度页面
go run main.go -c 1 -n 100 -u https://www.baidu.com/

# 使用debug模式请求百度页面
go run main.go -c 1 -n 1 -d true -u https://www.baidu.com/

# 使用 curl文件(文件在curl目录下) 的方式请求
go run main.go -c 1 -n 1 -p curl/baidu.curl.txt

# 压测webSocket连接
go run main.go -c 10 -n 10 -u ws://127.0.0.1:8089/acc
  • 使用 curl文件进行压测

curl是Linux在命令行下的工作的文件传输工具,是一款很强大的http命令行工具。

使用curl文件可以压测使用非GET的请求,支持设置http请求的 method、cookies、header、body等参数

chrome 浏览器生成 curl文件,打开开发者模式(快捷键F12),如图所示,生成 curl 在终端执行命令
copy cURL

生成内容粘贴到项目目录下的curl/baidu.curl.txt文件中,执行下面命令就可以从curl.txt文件中读取需要压测的内容进行压测了

# 使用 curl文件(文件在curl目录下) 的方式请求
go run main.go -c 1 -n 1 -p curl/baidu.curl.txt

4.3 实现

  • 具体需求可以查看项目源码
  • 项目目录结构
|____main.go                      // main函数,获取命令行参数
|____server                       // 处理程序目录
| |____dispose.go                 // 压测启动,注册验证器、启动统计函数、启动协程进行压测
| |____statistics                 // 统计目录
| | |____statistics.go            // 接收压测统计结果并处理
| |____golink                     // 建立连接目录
| | |____http_link.go             // http建立连接
| | |____websocket_link.go        // webSocket建立连接
| |____client                     // 请求数据客户端目录
| | |____http_client.go           // http客户端
| | |____websocket_client.go      // webSocket客户端
| |____verify                     // 对返回数据校验目录
| | |____http_verify.go           // http返回数据校验
| | |____websokcet_verify.go      // webSocket返回数据校验
|____heper                        // 通用函数目录
| |____heper.go                   // 通用函数
|____model                        // 模型目录
| |____request_model.go           // 请求数据模型
| |____curl_model.go              // curl文件解析
|____vendor                       // 项目依赖目录

4.4 go-stress-testing 对 Golang web 压测

这里使用go-stress-testing对go server进行压测(部署在同一台机器上),并统计压测结果

  • 申请的服务器配置

CPU: 4核 (Intel Xeon(Cascade Lake) Platinum 8269 2.5 GHz/3.2 GHz)

内存: 16G
硬盘: 20G SSD
系统: CentOS 7.6

go version: go1.12.9 linux/amd64

go-stress-testing01

  • go server
package main

import (
    "log"
    "net/http"
)

const (
    httpPort = "8088"
)

func main() {

    runtime.GOMAXPROCS(runtime.NumCPU() - 1)

    hello := func(w http.ResponseWriter, req *http.Request) {
        data := "Hello, World!"

        w.Header().Add("Server", "golang")
        w.Write([]byte(data))

        return
    }

    http.HandleFunc("/", hello)
    err := http.ListenAndServe(":"+httpPort, nil)

    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}
  • go_stress_testing 压测命令
./go_stress_testing_linux -c 100 -n 10000 -u http://127.0.0.1:8088/
  • 压测结果
并发数go_stress_testing QPS
16394.86
416909.36
1018456.81
2019490.50
3019947.47
5019922.56
8019155.33
10018336.46
20016813.86

从压测的结果上看:效果还不错,压测QPS有接近2W

5、压测工具的比较

5.1 比较

-ablocustJmetergo-stress-testing云压测
实现语言CPythonJavaGolang-
UI界面
优势使用简单,上手简单支持分布式、压测数据支持导出插件丰富,支持生成HTML报告项目开源,使用简单,没有依赖,支持webSocket压测更加真实的模拟用户,支持更高的压测力度

5.2 如何选择压测工具

这个世界上没有最好的,只有最适合的,工具千千万,选择一款适合你的才是最重要的

在实际使用中有各种场景,选择工具的时候就需要考虑这些:

  • 明确你的目的,需要做什么压测、压测的目标是什么?
  • 使用的工具你是否熟悉,你愿意花多大的成本了解它?
  • 你是为了测试还是想了解其中的原理?
  • 工具是否能支持你需要压测的场景

6、单台机器100w连接压测实战

6.1 说明

之前写了一篇文章,基于websocket单台机器支持百万连接分布式聊天(IM)系统(不了解这个项目可以查看上一篇或搜索一下文章),这里我们要实现单台机器支持100W连接的压测

目标:

  • 单台机器能保持100W个长连接
  • 机器的CPU、内存、网络、I/O 状态都正常

说明:

gowebsocket 分布式聊天(IM)系统:

  • 之前用户连接以后有个全员广播,这里需要将用户连接、退出等事件关闭
  • 服务器准备:
由于自己手上没有自己的服务器,所以需要临时购买的云服务器

压测服务器:

16台(稍后解释为什么需要16台机器)

CPU: 2核
内存: 8G
硬盘: 20G
系统: CentOS 7.6

webSocket压测服务器

被压测服务:

1台

CPU: 4核
内存: 32G
硬盘: 20G SSD
系统: CentOS 7.6

webSocket被压测服务器

6.2 内核优化

  • 修改程序最大打开文件数

被压测服务器需要保持100W长连接,客户和服务器端是通过socket通讯的,每个连接需要建立一个socket,程序需要保持100W长连接就需要单个程序能打开100W个文件句柄

# 查看系统默认的值
ulimit -n
# 设置最大打开文件数
ulimit -n 1040000

这里设置的要超过100W,程序除了有100W连接还有其它资源连接(数据库、资源等连接),这里设置为 104W

centOS 7.6 上述设置不生效,需要手动修改配置文件

vim /etc/security/limits.conf

这里需要把硬限制和软限制、root用户和所有用户都设置为 1040000

core 是限制内核文件的大小,这里设置为 unlimited

# 添加一下参数
root soft nofile 1040000
root hard nofile 1040000

root soft nofile 1040000
root hard nproc 1040000

root soft core unlimited
root hard core unlimited

* soft nofile 1040000
* hard nofile 1040000

* soft nofile 1040000
* hard nproc 1040000

* soft core unlimited
* hard core unlimited

注意:

/proc/sys/fs/file-max 表示系统级别的能够打开的文件句柄的数量,不能小于limits中设置的值

如果file-max的值小于limits设置的值会导致系统重启以后无法登录

# file-max 设置的值参考
cat /proc/sys/fs/file-max
12553500

修改以后重启服务器,ulimit -n 查看配置是否生效

6.3 客户端配置

由于linux端口的范围是 0~65535(2^16-1)这个和操作系统无关,不管linux是32位的还是64位的

这个数字是由于tcp协议决定的,tcp协议头部表示端口只有16位,所以最大值只有65535(如果每台机器多几个虚拟ip就能突破这个限制)

1024以下是系统保留端口,所以能使用的1024到65535

如果需要100W长连接,每台机器有 65535-1024 个端口, 100W / (65535-1024) ≈ 15.5,所以这里需要16台服务器

  • vim /etc/sysctl.conf 在文件末尾添加
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_mem = 786432 2097152 3145728
net.ipv4.tcp_rmem = 4096 4096 16777216
net.ipv4.tcp_wmem = 4096 4096 16777216

配置解释:

  • ip_local_port_range 表示TCP/UDP协议允许使用的本地端口号 范围:1024~65000
  • tcp_mem 确定TCP栈应该如何反映内存使用,每个值的单位都是内存页(通常是4KB)。第一个值是内存使用的下限;第二个值是内存压力模式开始对缓冲区使用应用压力的上限;第三个值是内存使用的上限。在这个层次上可以将报文丢弃,从而减少对内存的使用。对于较大的BDP可以增大这些值(注意,其单位是内存页而不是字节)
  • tcp_rmem 为自动调优定义socket使用的内存。第一个值是为socket接收缓冲区分配的最少字节数;第二个值是默认值(该值会被rmem_default覆盖),缓冲区在系统负载不重的情况下可以增长到这个值;第三个值是接收缓冲区空间的最大字节数(该值会被rmem_max覆盖)。
  • tcp_wmem 为自动调优定义socket使用的内存。第一个值是为socket发送缓冲区分配的最少字节数;第二个值是默认值(该值会被wmem_default覆盖),缓冲区在系统负载不重的情况下可以增长到这个值;第三个值是发送缓冲区空间的最大字节数(该值会被wmem_max覆盖)。

6.4 准备

  1. 在被压测服务器上启动Server服务(gowebsocket)
  2. 查看被压测服务器的内网端口
  3. 登录上16台压测服务器,这里我提前把需要优化的系统做成了镜像,申请机器的时候就可以直接使用这个镜像(参数已经调好)

压测服务器16台准备

  1. 启动压测
 ./go_stress_testing_linux -c 62500 -n 1  -u ws://192.168.0.74:443/acc

62500*16 = 100W 正好可以达到我们的要求

建立连接以后,-n 1发送一个ping的消息给服务器,收到响应以后保持连接不中断

  1. 通过 gowebsocket服务器的http接口,实时查询连接数和项目启动的协程数
  2. 压测过程中查看系统状态
# linux 命令
ps      # 查看进程内存、cup使用情况
iostat  # 查看系统IO情况
nload   # 查看网络流量情况
/proc/pid/status # 查看进程状态

6.5 压测数据

  • 压测以后,查看连接数到100W,然后保持10分钟观察系统是否正常
  • 观察以后,系统运行正常、CPU、内存、I/O 都正常,打开页面都正常
  • 压测完成以后的数据

查看goWebSocket连接数统计,可以看到 clientsLen连接数为100W,goroutine数量2000008个,每个连接两个goroutine加上项目启动默认的8个。这里可以看到连接数满足了100W

查看goWebSocket连接数统计

从压测服务上查看连接数是否达到了要求,压测完成的统计数据并发数为62500,是每个客户端连接的数量,总连接数: 62500*16=100W

压测服务16台 压测完成

  • 记录内存使用情况,分别记录了1W到100W连接数内存使用情况
连接数内存
10000281M
1000002.7g
2000005.4g
50000013.1g
100000025.8g

100W连接时的查看内存详细数据:

cat /proc/pid/status
VmSize: 27133804 kB

27133804/1000000≈27.1 100W连接,占用了25.8g的内存,粗略计算了一下,一个连接占用了27.1Kb的内存,由于goWebSocket项目每个用户连接起了两个协程处理用户的读写事件,所以内存占用稍微多一点

如果需要如何减少内存使用可以参考 @Roy11568780 大佬给的解决方案

传统的golang中是采用的一个goroutine循环read的方法对应每一个socket。实际百万链路场景中这是巨大的资源浪费,优化的原理也不是什么新东西,golang中一样也可以使用epoll的,把fd拿到epoll中,检测到事件然后在协程池里面去读就行了,看情况读写分别10-20的协程goroutine池应该就足够了

至此,压测已经全部完成,单台机器支持100W连接已经满足~

7、总结

到这里压测总算完成,本次压测花费16元巨款。

单台机器支持100W连接是实测是满足的,但是实际业务比较复杂,还是需要持续优化~

通过实现介绍什么是压测,在什么情况下需要压测,如果觉得现有的压测工具不适用,可以自己实现或者是改造成适合自己的工具。

8、参考文献

性能测试工具

性能测试常见名词解释

性能测试名词解释

PV、TPS、QPS是怎么计算出来的?

超实用压力测试工具-ab工具

Locust 介绍

Jmeter性能测试 入门

基于websocket单台机器支持百万连接分布式聊天(IM)系统

github 搜:link1st 查看项目 go-stress-testing

https://github.com/link1st/go-stress-testing

查看原文

赞 40 收藏 31 评论 1

link1st 发布了文章 · 2019-08-12

基于websocket单台机器支持百万连接分布式聊天(IM)系统

基于websocket单台机器支持百万连接分布式聊天(IM)系统

本文将介绍如何实现一个基于websocket分布式聊天(IM)系统。

使用golang实现websocket通讯,单机可以支持百万连接,使用gin框架、nginx负载、可以水平部署、程序内部相互通讯、使用grpc通讯协议。

本文内容比较长,如果直接想clone项目体验直接进入项目体验goWebSocket项目下载 ,文本从介绍webSocket是什么开始,然后开始介绍这个项目,以及在Nginx中配置域名做webSocket的转发,然后介绍如何搭建一个分布式系统。

目录

1、项目说明

1.1 goWebSocket

本文将介绍如何实现一个基于websocket聊天(IM)分布式系统。

使用golang实现websocket通讯,单机支持百万连接,使用gin框架、nginx负载、可以水平部署、程序内部相互通讯、使用grpc通讯协议。

  • 一般项目中webSocket使用的架构图

网站架构图

1.2 项目体验

2、介绍webSocket

2.1 webSocket 是什么

WebSocket 协议在2008年诞生,2011年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

  • HTTP和WebSocket在通讯过程的比较

HTTP协议和WebSocket比较

  • HTTP和webSocket都支持配置证书,ws:// 无证书 wss:// 配置证书的协议标识

HTTP协议和WebSocket比较

2.2 webSocket的兼容性

  • 浏览器的兼容性,开始支持webSocket的版本

浏览器开始支持webSocket的版本

  • 服务端的支持

golang、java、php、node.js、python、nginx 都有不错的支持

  • Android和IOS的支持

Android可以使用java-webSocket对webSocket支持

iOS 4.2及更高版本具有WebSockets支持

2.3 为什么要用webSocket

    1. 从业务上出发,需要一个主动通达客户端的能力
目前大多数的请求都是使用HTTP,都是由客户端发起一个请求,有服务端处理,然后返回结果,不可以服务端主动向某一个客户端主动发送数据

服务端处理一个请求

    1. 大多数场景我们需要主动通知用户,如:聊天系统、用户完成任务主动告诉用户、一些运营活动需要通知到在线的用户
    1. 可以获取用户在线状态
    1. 在没有长链接的时候通过客户端主动轮询获取数据
    1. 可以通过一种方式实现,多种不同平台(H5/Android/IOS)去使用

2.4 webSocket建立过程

    1. 客户端先发起升级协议的请求

客户端发起升级协议的请求,采用标准的HTTP报文格式,在报文中添加头部信息

Connection: Upgrade表明连接需要升级

Upgrade: websocket需要升级到 websocket协议

Sec-WebSocket-Version: 13 协议的版本为13

Sec-WebSocket-Key: I6qjdEaqYljv3+9x+GrhqA== 这个是base64 encode 的值,是浏览器随机生成的,与服务器响应的 Sec-WebSocket-Accept对应

# Request Headers
Connection: Upgrade
Host: im.91vh.com
Origin: http://im.91vh.com
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: I6qjdEaqYljv3+9x+GrhqA==
Sec-WebSocket-Version: 13
Upgrade: websocket

浏览器 Network

    1. 服务器响应升级协议

服务端接收到升级协议的请求,如果服务端支持升级协议会做如下响应

返回:

Status Code: 101 Switching Protocols 表示支持切换协议

# Response Headers
Connection: upgrade
Date: Fri, 09 Aug 2019 07:36:59 GMT
Sec-WebSocket-Accept: mB5emvxi2jwTUhDdlRtADuBax9E=
Server: nginx/1.12.1
Upgrade: websocket
    1. 升级协议完成以后,客户端和服务器就可以相互发送数据

websocket接收和发送数据

3、如何实现基于webSocket的长链接系统

3.1 使用go实现webSocket服务端

3.1.1 启动端口监听

  • websocket需要监听端口,所以需要在golang 成功的 main 函数中用协程的方式去启动程序
  • main.go 实现启动
go websocket.StartWebSocket()
  • init_acc.go 启动程序
// 启动程序
func StartWebSocket() {
    http.HandleFunc("/acc", wsPage)
    http.ListenAndServe(":8089", nil)
}

3.1.2 升级协议

  • 客户端是通过http请求发送到服务端,我们需要对http协议进行升级为websocket协议
  • 对http请求协议进行升级 golang 库gorilla/websocket 已经做得很好了,我们直接使用就可以了
  • 在实际使用的时候,建议每个连接使用两个协程处理客户端请求数据和向客户端发送数据,虽然开启协程会占用一些内存,但是读取分离,减少收发数据堵塞的可能
  • init_acc.go
func wsPage(w http.ResponseWriter, req *http.Request) {

    // 升级协议
    conn, err := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
        fmt.Println("升级协议", "ua:", r.Header["User-Agent"], "referer:", r.Header["Referer"])

        return true
    }}).Upgrade(w, req, nil)
    if err != nil {
        http.NotFound(w, req)

        return
    }

    fmt.Println("webSocket 建立连接:", conn.RemoteAddr().String())

    currentTime := uint64(time.Now().Unix())
    client := NewClient(conn.RemoteAddr().String(), conn, currentTime)

    go client.read()
    go client.write()

    // 用户连接事件
    clientManager.Register <- client
}

3.1.3 客户端连接的管理

  • 当前程序有多少用户连接,还需要对用户广播的需要,这里我们就需要一个管理者(clientManager),处理这些事件:
  • 记录全部的连接、登录用户的可以通过 appId+uuid 查到用户连接
  • 使用map存储,就涉及到多协程并发读写的问题,所以需要加读写锁
  • 定义四个channel ,分别处理客户端建立连接、用户登录、断开连接、全员广播事件
// 连接管理
type ClientManager struct {
    Clients     map[*Client]bool   // 全部的连接
    ClientsLock sync.RWMutex       // 读写锁
    Users       map[string]*Client // 登录的用户 // appId+uuid
    UserLock    sync.RWMutex       // 读写锁
    Register    chan *Client       // 连接连接处理
    Login       chan *login        // 用户登录处理
    Unregister  chan *Client       // 断开连接处理程序
    Broadcast   chan []byte        // 广播 向全部成员发送数据
}

// 初始化
func NewClientManager() (clientManager *ClientManager) {
    clientManager = &ClientManager{
        Clients:    make(map[*Client]bool),
        Users:      make(map[string]*Client),
        Register:   make(chan *Client, 1000),
        Login:      make(chan *login, 1000),
        Unregister: make(chan *Client, 1000),
        Broadcast:  make(chan []byte, 1000),
    }

    return
}

3.1.4 注册客户端的socket的写的异步处理程序

  • 防止发生程序崩溃,所以需要捕获异常
  • 为了显示异常崩溃位置这里使用string(debug.Stack())打印调用堆栈信息
  • 如果写入数据失败了,可能连接有问题,就关闭连接
  • client.go
// 向客户端写数据
func (c *Client) write() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("write stop", string(debug.Stack()), r)

        }
    }()

    defer func() {
        clientManager.Unregister <- c
        c.Socket.Close()
        fmt.Println("Client发送数据 defer", c)
    }()

    for {
        select {
        case message, ok := <-c.Send:
            if !ok {
                // 发送数据错误 关闭连接
                fmt.Println("Client发送数据 关闭连接", c.Addr, "ok", ok)

                return
            }

            c.Socket.WriteMessage(websocket.TextMessage, message)
        }
    }
}

3.1.5 注册客户端的socket的读的异步处理程序

  • 循环读取客户端发送的数据并处理
  • 如果读取数据失败了,关闭channel
  • client.go
// 读取客户端数据
func (c *Client) read() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("write stop", string(debug.Stack()), r)
        }
    }()

    defer func() {
        fmt.Println("读取客户端数据 关闭send", c)
        close(c.Send)
    }()

    for {
        _, message, err := c.Socket.ReadMessage()
        if err != nil {
            fmt.Println("读取客户端数据 错误", c.Addr, err)

            return
        }

        // 处理程序
        fmt.Println("读取客户端数据 处理:", string(message))
        ProcessData(c, message)
    }
}

3.1.6 接收客户端数据并处理

  • 约定发送和接收请求数据格式,为了js处理方便,采用了json的数据格式发送和接收数据(人类可以阅读的格式在工作开发中使用是比较方便的)
  • 登录发送数据示例:
{"seq":"1565336219141-266129","cmd":"login","data":{"userId":"马远","appId":101}}
  • 登录响应数据示例:
{"seq":"1565336219141-266129","cmd":"login","response":{"code":200,"codeMsg":"Success","data":null}}
  • websocket是双向的数据通讯,可以连续发送,如果发送的数据需要服务端回复,就需要一个seq来确定服务端的响应是回复哪一次的请求数据
  • cmd 是用来确定动作,websocket没有类似于http的url,所以规定 cmd 是什么动作
  • 目前的动作有:login/heartbeat 用来发送登录请求和连接保活(长时间没有数据发送的长连接容易被浏览器、移动中间商、nginx、服务端程序断开)
  • 为什么需要AppId,UserId是表示用户的唯一字段,设计的时候为了做成通用性,设计AppId用来表示用户在哪个平台登录的(web、app、ios等),方便后续扩展
  • request_model.go 约定的请求数据格式
/************************  请求数据  **************************/
// 通用请求数据格式
type Request struct {
    Seq  string      `json:"seq"`            // 消息的唯一Id
    Cmd  string      `json:"cmd"`            // 请求命令字
    Data interface{} `json:"data,omitempty"` // 数据 json
}

// 登录请求数据
type Login struct {
    ServiceToken string `json:"serviceToken"` // 验证用户是否登录
    AppId        uint32 `json:"appId,omitempty"`
    UserId       string `json:"userId,omitempty"`
}

// 心跳请求数据
type HeartBeat struct {
    UserId string `json:"userId,omitempty"`
}
  • response_model.go
/************************  响应数据  **************************/
type Head struct {
    Seq      string    `json:"seq"`      // 消息的Id
    Cmd      string    `json:"cmd"`      // 消息的cmd 动作
    Response *Response `json:"response"` // 消息体
}

type Response struct {
    Code    uint32      `json:"code"`
    CodeMsg string      `json:"codeMsg"`
    Data    interface{} `json:"data"` // 数据 json
}

3.1.7 使用路由的方式处理客户端的请求数据

  • 使用路由的方式处理由客户端发送过来的请求数据
  • 以后添加请求类型以后就可以用类是用http相类似的方式(router-controller)去处理
  • acc_routers.go
// Websocket 路由
func WebsocketInit() {
    websocket.Register("login", websocket.LoginController)
    websocket.Register("heartbeat", websocket.HeartbeatController)
}

3.1.8 防止内存溢出和Goroutine不回收

    1. 定时任务清除超时连接

没有登录的连接和登录的连接6分钟没有心跳则断开连接

client_manager.go

// 定时清理超时连接
func ClearTimeoutConnections() {
    currentTime := uint64(time.Now().Unix())

    for client := range clientManager.Clients {
        if client.IsHeartbeatTimeout(currentTime) {
            fmt.Println("心跳时间超时 关闭连接", client.Addr, client.UserId, client.LoginTime, client.HeartbeatTime)

            client.Socket.Close()
        }
    }
}
    1. 读写的Goroutine有一个失败,则相互关闭

write()Goroutine写入数据失败,关闭c.Socket.Close()连接,会关闭read()Goroutine
read()Goroutine读取数据失败,关闭close(c.Send)连接,会关闭write()Goroutine

    1. 客户端主动关闭

关闭读写的Goroutine
ClientManager删除连接

    1. 监控用户连接、Goroutine数

十个内存溢出有九个和Goroutine有关
添加一个http的接口,可以查看系统的状态,防止Goroutine不回收
查看系统状态

    1. Nginx 配置不活跃的连接释放时间,防止忘记关闭的连接
    1. 使用 pprof 分析性能、耗时

3.2 使用javaScript实现webSocket客户端

3.2.1 启动并注册监听程序

  • js 建立连接,并处理连接成功、收到数据、断开连接的事件处理
ws = new WebSocket("ws://127.0.0.1:8089/acc");

 
ws.onopen = function(evt) {
  console.log("Connection open ...");
};
 
ws.onmessage = function(evt) {
  console.log( "Received Message: " + evt.data);
  data_array = JSON.parse(evt.data);
  console.log( data_array);
};
 
ws.onclose = function(evt) {
  console.log("Connection closed.");
};

3.2.2 发送数据

  • 需要注意:连接建立成功以后才可以发送数据
  • 建立连接以后由客户端向服务器发送数据示例
登录:
ws.send('{"seq":"2323","cmd":"login","data":{"userId":"11","appId":101}}');

心跳:
ws.send('{"seq":"2324","cmd":"heartbeat","data":{}}');
 
关闭连接:
ws.close();

4、goWebSocket 项目

4.1 项目说明

  • 本项目是基于webSocket实现的分布式IM系统
  • 客户端随机分配用户名,所有人进入一个聊天室,实现群聊的功能
  • 单台机器(24核128G内存)支持百万客户端连接
  • 支持水平部署,部署的机器之间可以相互通讯
  • 项目架构图

网站架构图

4.2 项目依赖

  • 本项目只需要使用 redis 和 golang
  • 本项目使用govendor管理依赖,克隆本项目就可以直接使用
# 主要使用到的包
github.com/gin-gonic/gin@v1.4.0
github.com/go-redis/redis
github.com/gorilla/websocket
github.com/spf13/viper
google.golang.org/grpc
github.com/golang/protobuf

4.3 项目启动

  • 克隆项目
git clone git@github.com:link1st/gowebsocket.git
# 或
git clone https://github.com/link1st/gowebsocket.git
  • 修改项目配置
cd gowebsocket
cd config
mv app.yaml.example app.yaml
# 修改项目监听端口,redis连接等(默认127.0.0.1:3306)
vim app.yaml
# 返回项目目录,为以后启动做准备
cd ..
  • 配置文件说明
app:
  logFile: log/gin.log # 日志文件位置
  httpPort: 8080 # http端口
  webSocketPort: 8089 # webSocket端口
  rpcPort: 9001 # 分布式部署程序内部通讯端口
  httpUrl: 127.0.0.1:8080
  webSocketUrl:  127.0.0.1:8089


redis:
  addr: "localhost:6379"
  password: ""
  DB: 0
  poolSize: 30
  minIdleConns: 30
  • 启动项目
go run main.go
  • 进入IM聊天地址

http://127.0.0.1:8080/home/index

  • 到这里,就可以体验到基于webSocket的IM系统

5、webSocket项目Nginx配置

5.1 为什么要配置Nginx

  • 使用nginx实现内外网分离,对外只暴露Nginx的Ip(一般的互联网企业会在nginx之前加一层LVS做负载均衡),减少入侵的可能
  • 使用Nginx可以利用Nginx的负载功能,前端再使用的时候只需要连接固定的域名,通过Nginx将流量分发了到不同的机器
  • 同时我们也可以使用Nginx的不同的负载策略(轮询、weight、ip_hash)

5.2 nginx配置

  • 使用域名 im.91vh.com 为示例,参考配置
  • 一级目录im.91vh.com/acc 是给webSocket使用,是用nginx stream转发功能(nginx 1.3.31 开始支持,使用Tengine配置也是相同的),转发到golang 8089 端口处理
  • 其它目录是给HTTP使用,转发到golang 8080 端口处理
upstream  go-im
{
    server 127.0.0.1:8080 weight=1 max_fails=2 fail_timeout=10s;
    keepalive 16;
}

upstream  go-acc
{
    server 127.0.0.1:8089 weight=1 max_fails=2 fail_timeout=10s;
    keepalive 16;
}


server {
    listen       80 ;
    server_name  im.91vh.com;
    index index.html index.htm ;


    location /acc {
        proxy_set_header Host $host;
        proxy_pass http://go-acc;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Connection "";
        proxy_redirect off;
        proxy_intercept_errors on;
        client_max_body_size 10m;
    }

    location /
    {
        proxy_set_header Host $host;
        proxy_pass http://go-im;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_redirect off;
        proxy_intercept_errors on;
        client_max_body_size 30m;
    }

    access_log  /link/log/nginx/access/im.log;
    error_log   /link/log/nginx/access/im.error.log;
}

5.3 问题处理

  • 运行nginx测试命令,查看配置文件是否正确
/link/server/tengine/sbin/nginx -t
  • 如果出现错误
nginx: [emerg] unknown "connection_upgrade" variable
configuration file /link/server/tengine/conf/nginx.conf test failed
  • 处理方法
  • nginx.com添加
http{
    fastcgi_temp_file_write_size 128k;
..... # 需要添加的内容

    #support websocket
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

.....
    gzip on;
    
}
  • 原因:Nginx代理webSocket的时候就会遇到Nginx的设计问题 End-to-end and Hop-by-hop Headers

6、压测

6.1 Linux内核优化

  • 设置文件打开句柄数
ulimit -n 1000000
  • 设置sockets连接参数
vim /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 0

6.2 压测准备

  • 待压测,如果大家有压测的结果欢迎补充

6.3 压测数据

  • 项目在实际使用的时候,每个连接约占 24Kb内存,一个Goroutine 约占11kb
  • 支持百万连接需要22G内存
在线用户数cup内存I/Onet.out
1W
10W
100W

7、如何基于webSocket实现一个分布式Im

7.1 说明

获取全部在线的用户,查询单前服务的全部用户+集群中服务的全部用户
发送消息,这里采用的是http接口发送(微信网页版发送消息也是http接口),这里考虑主要是两点:
1.服务分离,让acc系统尽量的简单一点,不掺杂其它业务逻辑
2.发送消息是走http接口,不使用webSocket连接,才用收和发送数据分离的方式,可以加快收发数据的效率

7.2 架构

  • 项目启动注册和用户连接时序图

用户连接时序图

  • 其它系统(IM、任务)向webSocket(acc)系统连接的用户发送消息时序图

分布是系统随机给用户发送消息

8、回顾和反思

8.1 在其它系统应用

  • 本系统设计的初衷就是:和客户端保持一个长链接、对外部系统两个接口(查询用户是否在线、给在线的用户推送消息),实现业务的分离
  • 只有和业务分离可,才可以供多个业务使用,而不是每个业务都建立一个长链接

8.2 已经实现的功能

  • gin log日志(请求日志+debug日志)
  • 读取配置文件 完成
  • 定时脚本,清理过期未心跳链接 完成
  • http接口,获取登录、链接数量 完成
  • http接口,发送push、查询有多少人在线 完成
  • grpc 程序内部通讯,发送消息 完成
  • appIds 一个用户在多个平台登录
  • 界面,把所有在线的人拉倒一个群里面,发送消息 完成
  • 单聊、群聊 完成
  • 实现分布式,水平扩张 完成
  • 压测脚本
  • 文档整理
  • 文档目录、百万长链接的实现、为什么要实现一个IM、怎么实现一个Im
  • 架构图以及扩展

IM实现细节:

  • 定义文本消息结构 完成
  • html发送文本消息 完成
  • 接口接收文本消息并发送给全体 完成
  • html接收到消息 显示到界面 完成
  • 界面优化 需要持续优化
  • 有人加入以后广播全体 完成
  • 定义加入聊天室的消息结构 完成
  • 引入机器人 待定

8.2 需要完善、优化

  • 登录,使用微信登录 获取昵称、头像等
  • 有账号系统、资料系统
  • 界面优化、适配手机端
  • 消息 文本消息(支持表情)、图片、语音、视频消息
  • 微服务注册、发现、熔断等
  • 添加配置项,单台机器最大连接数量

8.3 总结

  • 虽然实现了一个分布式在聊天的IM,但是有很多细节没有处理(登录没有鉴权、界面还待优化等),但是可以通过这个示例可以了解到:通过WebSocket解决很多业务上需求
  • 本文虽然号称单台机器能有百万长链接(内存上能满足),但是实际在场景远比这个复杂(cpu有些压力),当然了如果你有这么大的业务量可以购买更多的机器更好的去支撑你的业务,本程序只是演示如何在实际工作用使用webSocket.
  • 参考本文,你可以实现出来符合你需要的程序

9、参考文献

维基百科 WebSocket

阮一峰 WebSocket教程

WebSocket协议:5分钟从入门到精通

link1st gowebsocket

查看原文

赞 31 收藏 25 评论 0

link1st 关注了专栏 · 2019-08-12

融云分析

关注即时通讯和实时音视频领域,分享探讨相关领域技术.

关注 981

link1st 关注了专栏 · 2019-08-12

腾讯云技术社区

最专业的云解读社区

关注 11492

link1st 关注了专栏 · 2019-08-12

Web邦邦堂

欢迎订阅前端邦邦堂专栏 前端邦邦堂是一群初入IT编程的人共同组成。用意是互帮互助,共同成长。 Qq群号:135170291

关注 1222

认证与成就

  • 获得 77 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2019-08-12
个人主页被 708 人浏览