Go商城秒杀系统

仓库地址:https://github.com/qingbo1011/iris-seckill

参考学习视频:全流程开发 GO实战电商网站高并发秒杀系统

更好的学习课程:Gin+Vue+微服务打造秒杀商城-Go语言

主要学习一下秒杀的思路,不用太拘泥于项目接口细节。因为这个项目不是前后端分离的项目,视频的内容比较老,所以我也做了一些改进。

技术栈

  • Iris
  • gorm
  • MySQL
  • Redis
  • RabbitMQ
  • go html/template
  • 一致性Hash算法

iris框架用起来,感觉还是没有gin舒服,gin的风格更加贴切go。

gorm选择的版本是gorm v1。后续可以升级到gorm v2。

go html/template包参考笔记:Template

关于Iris的教程,直接去看官网。官方提供的文档真的是非常非常好,强烈推荐!

Quick start (iris-go.com)

Iris安装

Iris的安装有点小坑,必须要手动go get github.com/kataras/iris/v12@master,如果只靠goland的Sync,下载下来的包是不完整的。

路由分析

通过controller如何分析出路由呢,以GET请求,/product/all为例:

在main.go的注册路由器中:

controller/product.go中:

所以这个接口的cURL是:

1
curl --location --request GET 'http://127.0.0.1:8080/product/all'

这种的,该怎么说呢😯😥😔

而且本项目并不是前后端分离架构,所以接口返回的不是JSON串而是html

注意事项

  • 修改这里,我都是根据产品的名称进行修改的。(因为根据产品id,需要从html中解析。不想浪费太多的时间在这里,注重秒杀逻辑就好。)所以在修改时注意不要修改产品名称!
  • gorm使用v1,以后可以升级为v2
  • 暂时没有加分页查询
  • 订单相关的GetAllOrderInfo api没有做好(主要是看不懂他那个map[int]map[string]string到底是要返回个啥?)
  • 在前台的product controller的GetDetail中,这里是直接把product_id写死了(视频中也是这样,因为我们关注的是秒杀功能)
  • 登录可完善:结合redis
  • 跨平台交叉编译(在 Mac、Linux、Windows 下Go交叉编译(注意windows下面 powershell不行,要cmd)

Wrk压测工具介绍

wrk - a HTTP benchmarking tool

github:https://github.com/wg/wrk

性能测试工具 wrk 使用教程

Wrk只支持类Unix系统,能用少量的线程测大量的连接。

Ubuntu/Debian下安装,依次执行如下命令:

1
2
3
4
5
6
sudo apt-get install build-essential libssl-dev git -y
git clone https://github.com/wg/wrk.git wrk
cd wrk
make
# 将可执行文件移动到 /usr/local/bin 位置
sudo cp wrk /usr/local/bin

CentOS / RedHat / Fedora下安装,依次执行如下命令:

1
2
3
4
5
6
7
sudo yum groupinstall 'Development Tools'
sudo yum install -y openssl-devel git
git clone https://github.com/wg/wrk.git wrk
cd wrk
make
# 将可执行文件移动到 /usr/local/bin 位置
sudo cp wrk /usr/local/bin

验证是否安装成功:wrk -v

执行压测命令:

1
wrk -t12 -c400 -d30s --latency http://www.baidu.com

命令说明:使用12个线程400个连接,对http://www.baidu.com进行了30秒的压测,并要求在压测结果中输出响应延迟信息。

执行上面的压测命令,30 秒压测过后,生成如下压测报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Running 30s test @ http://www.baidu.com 
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 386.32ms 380.75ms 2.00s 86.66%
Req/Sec 17.06 13.91 252.00 87.89%
Latency Distribution
50% 218.31ms
75% 520.60ms
90% 955.08ms
99% 1.93s
4922 requests in 30.06s, 73.86MB read
Socket errors: connect 0, read 0, write 0, timeout 311
Requests/sec: 163.76
Transfer/sec: 2.46MB

我们来具体说一说,报告中各项指标都代表什么意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Running 30s test @ http://www.baidu.com (压测时间30s)
12 threads and 400 connections (共12个测试线程,400个连接)
(平均值) (标准差) (最大值)(正负一个标准差所占比例)
Thread Stats Avg Stdev Max +/- Stdev
(延迟)
Latency 386.32ms 380.75ms 2.00s 86.66%
(每秒请求数)
Req/Sec 17.06 13.91 252.00 87.89%
Latency Distribution (延迟分布)
50% 218.31ms
75% 520.60ms
90% 955.08ms
99% 1.93s
4922 requests in 30.06s, 73.86MB read (30.06s内处理了4922个请求,耗费流量73.86MB)
Socket errors: connect 0, read 0, write 0, timeout 311 (发生错误数)
Requests/sec: 163.76 (QPS 163.76,即平均每秒处理请求数为163.76)
Transfer/sec: 2.46MB (平均每秒流量2.46MB)

可以看到,压测报告还是非常直观的!

标准差如果太大说明样本本身离散程度比较高,有可能系统性能波动较大。)

秒杀逻辑和优化

最基础的版本中,存在问题如下:

秒杀分布式架构设计

总体来说分为两步:

  1. 筛选有效流量
  2. 异步处理数据

前端优化

  • 页面静态化(通过代码实现,在项目中通过go语言在front/web/GetGenerateHtmlcontroller/product下的generateStaticHtml()函数)

CDN

服务端优化

后端优化:

  • 突破Session限制
  • 分布式接口实现
  • 解决接口超卖问题
  • 引入RabbitMQ实现秒杀队列

优化思路:

  • 系统特征:高并发,大流量
  • 优化方向:提高网站性能,保护数据库
  • 具体措施:静态化、分布式、消息队列

优化架构:

突破Session限制

  • Cookie替代Session集群
  • 登录代码重构

代码体现在:

  • 新增util/encrypt.go的加密解密方法
  • 重构登录逻辑/front/web/controller/user.go下的PostLogin 用户登陆接口
    • 弃用session,存储cookie

分布式接口实现

  • 引入分布式及代码架构调整
  • 一致性Hash原理
  • 分布式存储实现(突破Redis瓶颈限制)

一致性Hash算法:

  • 用途:快速定位资源,均匀分布
  • 场景:分布式存储,分布式缓存,负载均衡

原理:

解决服务器较少情况下的数据倾斜问题:

代码体现在:

  • util/下新增过滤器filter.go
  • 在根目录下新增validate.go
  • util/下新增consistent.go,实现一致性Hash算法

分布式存储实现(突破Redis瓶颈限制)

Redis使用方式:

  • 单机或者主从:单击QPS 8w左右

  • Redis Cluster集群:QPS能达千万以上

  • Redis分布式:QPS能达千万以上

代码体现在:

  • 在根目录下新建getOne.go

压力测试结果:27w QPS

引入RabbitMQ

这里使用Simple模式进行学习(Go操作RabbitMQ

代码体现在:

  • front/web/controller/下的product.go中,修改GetOrder的下订单逻辑
  • 新建mq/rabbit/下的message.go
  • 在根目录下新建consumer.go

rabbit-生产者-消费者

rabbit.go代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
package simple

import (
"encoding/json"
"fmt"
"iris-seckill/conf"
"iris-seckill/mq/rabbit"
"iris-seckill/service"
"log"
"strings"

"github.com/streadway/amqp"
)

// Rabbit RabbitMQ结构体
type Rabbit struct {
conn *amqp.Connection
channel *amqp.Channel
QueueName string // 队列名称
Exchange string // 交换机名称
Key string // bind Key 名称
MqUrl string // 连接信息
}

// NewRabbitMQ 创建Rabbit结构体实例
func NewRabbitMQ(queueName, exchange, key string) *Rabbit {
// rmqURL := "amqp://qingbo:qingbo@43.138.57.192:5672/qingbo"
var builder strings.Builder
s := []string{"amqp://", conf.RabbitMQUser, ":", conf.RabbitMQPassword, "@", conf.RabbitMQHost, "/", conf.RabbitMQVirtualHosts}
for _, str := range s {
builder.WriteString(str)
}
rmqURL := builder.String()
return &Rabbit{
QueueName: queueName,
Exchange: exchange,
Key: key,
MqUrl: rmqURL,
}
}

// Destroy 断开channel和connection
func (r Rabbit) Destroy() error {
err := r.channel.Close()
err = r.conn.Close()
return err
}

// 错误处理函数
func (r Rabbit) failOnErr(err error, msg string) {
if err != nil {
log.Fatal(msg, err)
}
}

// NewRabbitMQSimple 创建Simple模式下RabbitMQ实例
func NewRabbitMQSimple(queueName string) *Rabbit {
rabbitMQ := NewRabbitMQ(queueName, "", "") // 创建RabbitMQ实例
var err error
rabbitMQ.conn, err = amqp.Dial(rabbitMQ.MqUrl) // 获取connection
rabbitMQ.failOnErr(err, "failed to connect RabbitMQ")
rabbitMQ.channel, err = rabbitMQ.conn.Channel() // 获取channel
rabbitMQ.failOnErr(err, "failed to open a channel")
return rabbitMQ
}

// PublishSimple Simple模式 生产者
func (r Rabbit) PublishSimple(msg string) error {
// 1.申请队列,如果队列不存在会自动创建,存在则跳过创建
_, err := r.channel.QueueDeclare(
r.QueueName, // 队列名
false, // 是否持久化
false, // 是否自动删除
false, // 是否具有排他性
false, // 是否阻塞处理
nil, // 其他额外的属性
)
if err != nil {
log.Println(err)
return err
}
// 2.调用channel 发送消息到队列中
err = r.channel.Publish(
r.Exchange,
r.QueueName,
false, // 如果为true,根据自身exchange类型和routekey规则无法找到符合条件的队列会把消息返还给发送者
false, // 如果为true,当exchange发送消息到队列后发现队列上没有消费者,则会把消息返还给发送者
amqp.Publishing{
ContentType: "text/plain",
Body: []byte(msg),
},
)
if err != nil {
log.Println(err)
return err
}
return nil
}

// ConsumeSimple Simple模式 消费者
func (r Rabbit) ConsumeSimple(orderService service.IOrderService, productService service.IProductService) {
// 1.申请队列,如果队列不存在会自动创建,存在则跳过创建
queue, err := r.channel.QueueDeclare(
r.QueueName, // 队列名
false, // 是否持久化
false, // 是否自动删除
false, // 是否具有排他性
false, // 是否阻塞处理
nil, // 额外的属性
)
if err != nil {
log.Println(err)
}

// 消费者流量控制
r.channel.Qos(
1, // 当前消费者一次能接受的最大消息数量
0, // 服务器传递的最大容量(以八位字节为单位)
false, // 如果设置为true 对全局channel可用
)

// 2.消费消息
msgs, err := r.channel.Consume(
queue.Name, // 队列名称
"", // 用来区分多个消费者
false, // 是否自动应答
false, // 是否独有
false, // 设置为true,表示不能将同一个Connection中生产者发送的消息传递给这个Connection中的消费者
false, // 队列是否阻塞
nil, // 额外的属性
)
if err != nil {
log.Println(err)
}

// 3.启用协程处理消息
forever := make(chan bool) // 开个channel阻塞住,让开启的协程能一直跑着
go func() {
for delivery := range msgs {
// 消息逻辑处理,可以自行设计逻辑
fmt.Println("Received a message:", string(delivery.Body))
message := &rabbit.Message{}
err = json.Unmarshal([]byte(delivery.Body), message)
if err != nil {
log.Println(err)
}
_, err = orderService.InsertOrderByMessage(message) // 插入订单
if err != nil {
log.Println(err)
}
err = productService.SubNumberOne(message.ProductID) // 扣除商品数量
if err != nil {
log.Println(err)
}

delivery.Ack(false) // 如果为true表示确认所有未确认的消息;为false则表示确认当前消息
}
}()
fmt.Println(" [*] Waiting for messages.")
<-forever
}

rabbit.goGo操作RabbitMQ笔记中的差别:

1
2
3
4
5
6
7
8
9
10
// 2.消费消息
msgs, err := r.channel.Consume(
queue.Name, // 队列名称
"", // 用来区分多个消费者
false, // 是否自动应答
false, // 是否独有
false, // 设置为true,表示不能将同一个Connection中生产者发送的消息传递给这个Connection中的消费者
false, // 队列是否阻塞
nil, // 额外的属性
)
  • autoAck选择为false,表示手动应答
1
2
3
4
5
6
7
8
9
10
// 3.启用协程处理消息
forever := make(chan bool) // 开个channel阻塞住,让开启的协程能一直跑着
go func() {
for delivery := range msgs {
// 消息逻辑处理,可以自行设计逻辑
fmt.Println("Received a message:", string(delivery.Body))
...
delivery.Ack(false)
}
}()
  • autoAck选择为false,这里一定要设置delivery.Ack(false)!如果为true表示确认所有未确认的消息;为false则表示确认当前消息。
1
2
3
4
5
6
// 消费者流量控制
r.channel.Qos(
1, // 当前消费者一次能接受的最大消息数量
0, // 服务器传递的最大容量(以八位字节为单位)
false, // 如果设置为true 对全局channel可用
)
  • 别忘了消费者流控,防止暴库。

生产者部分代码:

front/下的main.go,指定了生产者产生的消息publish到sec-kill队列中:

1
2
3
rabbitmq := simple.NewRabbitMQSimple("sec-kill")
......
productParty := app.Party("/product")

front/web/controller/下的product.go中,修改GetOrder的下订单逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// GetOrder 下订单
func (p *ProductController) GetOrder() []byte {
productString := p.Ctx.URLParam("productID")
userString := p.Ctx.GetCookie("uid")
productID, err := strconv.Atoi(productString)
if err != nil {
p.Ctx.Application().Logger().Debug(err)
}
/* 引入RabbitMQ的下单逻辑 */
userID, err := strconv.ParseUint(userString, 10, 0)
if err != nil {
p.Ctx.Application().Logger().Debug(err)
}
message := rabbit.NewMessage(uint(userID), uint(productID)) // 创建消息体
messageBytes, err := json.Marshal(message)
if err != nil {
p.Ctx.Application().Logger().Debug(err)
}
err = p.RabbitMQ.PublishSimple(string(messageBytes))
if err != nil {
p.Ctx.Application().Logger().Debug(err)
}

return []byte("true")
}

消费者部分代码:

在根目录下新建consumer.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"iris-seckill/mq/rabbit/simple"
"iris-seckill/service"
)

func main() {
productService := service.NewProductService() // 创建productService
orderService := service.NewOrderService() // 创建orderService
rabbitMQSimple := simple.NewRabbitMQSimple("sec-kill")

rabbitMQSimple.ConsumeSimple(orderService, productService)
}

实现效果

先把front/下的main.go跑起来(消息的生产者),然后访问http://127.0.0.1:8082/product/order?productID=2,多刷新几次(消息的积累),然后运行根目录下的consumer.go。会发现消息队列中挤压的消息被消费了,而且数据库中的产品数量productNum也相应减少了。

安全优化

  • 前端页面限流
  • 服务端防止for循环请求
  • 其他安全建议
    • 添加图像验证
    • 添加自定义限流,如漏桶策略和令牌桶策略Gin限流 笔记
    • 添加隐藏秒杀接口地址

心得

iris框架用来,感觉还是没有gin舒服。gin还是更符合go的风格,更贴近go的哲学。这次使用iris主要还是想开开眼界。

项目主要理解秒杀逻辑,对其他接口就比较“不拘小节”。