执行异步任务时, 比如需要处理 10W 个订单, 如果是 PHP, 我们一般会配置一个定时任务, 然后该定时任务就会在单机上执行; 如果是 GO 或者 JAVA, 我们也需要使用相应的策略, 保证该任务只在单机上执行, 比如分布式锁。可能有同学会问, 我直接在多机上执行同一个任务不行么, 我只想说, 你胆子真大, 当多机同时处理一条数据, 你会死的很惨的。
那我们是否有一种方法, 可以让任务在多机同时执行, 然后又可以避免多机同时处理相同数据的问题呢? 这里给大家介绍一种多机分片的方式, 也是最近在公司 Get 到的新技能。
最近在做异步任务迁移, 要求对 DB 中的订单进行处理, 因为订单的数量非常大, 10W 的数量级是常规状态, 如果只通过一台机器去处理, 执行效率非常低, 所以需要通过多机并发处理。
对于上述方式, 其实还有另外一种解决方案, 就是单机执行任务, 然后把任务放入消息队列, 再新增一个接口, 用于消费队列中的数据, 然后进行数据处理, 因为接口对应的服务是集群部署, 所以执行速度很快, 不过这里在设计方案时, 需要考虑消息重复消费, 多机可能同时处理单条消息, 网络异常导致消息未得到处理等问题, 具体解决方案, 欢迎大家线下和我讨论哈。
什么是多机分片呢? 说的通俗一点, 就是把数据分成 N 份, 分别给每一台机器执行。比如我们有 1000 条数据, 通过相应策略, 将数据分成 5 份, 每份数据 200 条, 如果我们有 5 台机器, 那么每台机器可以分别处理 200 条数据。
那么具体是怎么实现?
为了更好讲解, 我先简单模拟一下场景:
将数据分成 10 分, 就有 10 个令牌, 即 number=10, 分别为 0、1、2 … 9, 处理逻辑如下:
Redis Incr 命令将 key 中储存的数字值增一。如果 key 不存在, 那么 key 的值会先被初始化为 0 , 然后再执行 INCR 操作。
机器的线程拿到令牌后, 就可以去分片获取数据了, 假如 DB 的数据结构如下, 且只有 20 条数据:
订单号 orderId | 商品名 productName | 配送状态 status |
---|---|---|
0 | 数据线 | 0 |
1 | 键盘 | 0 |
2 | 显示器 | 0 |
… | … | … |
19 | 鼠标 | 0 |
下面看一下分片获取数据流程:
这里需要用到分布式锁, 分布式锁的代码, 已经在文章"Redis 实现分布式锁"中已经说明, 下面先看获取释放令牌的代码:
const NO_INDEX = 100
const REDIS_ALIAS_NAME = "jointly"
const TASK_NAME = "task_name"
const RANGE_DATA = int64(10)
const PAGE_SIZE = int64(2)
// 分片任务
type FragmentTask struct {
RedisLock
}
// 获取令牌
func (f *FragmentTask) GetToken(processId int64) (tokenId int64, err error) {
i := 0
for {
increase, err := redis.Incr(REDIS_ALIAS_NAME, TASK_NAME)
if err != nil {
return 0, err
}
tokenId := increase % RANGE_DATA
lockKey := TASK_NAME + string(tokenId)
if f.GetDistributeLock(lockKey, 60) {
fmt.Printf("Get lock key success, processId:%d, tokenId:%d\n", processId, tokenId)
return tokenId, nil
}
fmt.Printf("Get lock key conflict, processId:%d, tokenId:%d\n", processId, tokenId)
i++
if int64(i) >= RANGE_DATA {
fmt.Printf("Begin a new cycle.\n")
return NO_INDEX, nil
}
}
}
// 释放令牌锁
func (f *FragmentTask) ReleaseToken(tokenId int64) bool {
lockKey := TASK_NAME + string(tokenId)
ret := f.DelDistributeLock(lockKey)
if !ret {
fmt.Printf("Release token failed, tokenId:%d\n", tokenId)
}
return ret
}
令牌生成的流程, 前面已经详细讲解, 这里需要注意的是, 我们每次只遍历 rangData 范围, 超过该范围后会退出, 外层其实有个循环, 会重新进入。
我们再看看通过令牌获取分片数据的逻辑:
func ( *Order) QueryOrderList(rangeData, tokenId, pageSize int64) (data []OrderData, err error){
o := orm.NewOrm()
o.Using("default")
num, err := o.Raw("SELECT * from "+ "tb_order where status = 0 and order_id % ? = ? limit ?", rangeData, tokenId, pageSize).QueryRows(&data)
if err != nil {
return nil, err
}
if num > 0 {
}
return data, nil
}
下面是单个线程的任务处理流程:
// 处理任务
func (f *FragmentTask) DoProcess(processId int64) error {
order := &db.Order{}
for {
tokenId, err := f.GetToken(processId)
if err != nil {
fmt.Printf("failed, exist!\n")
return err
}
// 所有的令牌都锁住了, 睡眠以后, 再重新执行
if tokenId == NO_INDEX {
fmt.Printf("All token is conflict, sleep for a while.\n")
time.Sleep(time.Second * 8)
continue
}
orderList, err := order.QueryOrderList(RANGE_DATA, tokenId, PAGE_SIZE)
if err != nil {
fmt.Printf("Query order list failed, tokenId:%d, err:%s\n", tokenId, err.Error())
f.ReleaseToken(tokenId)
continue
}
fmt.Printf("Begin to process, processId:%d, tokenId:%d, print orderList:%v\n", processId, tokenId, orderList)
// 处理任务, 用 sleep 模拟
time.Sleep(time.Second * 1)
// 处理完数据, 更新 DB 记录状态
for _, orderRecord := range orderList {
orderRecord.Status = 1
order.UpdateOrderStatus(&orderRecord)
}
f.ReleaseToken(tokenId)
}
return nil
}
这个逻辑是不是很清晰, 就是一个 SQL 查询。最后就是多线程处理逻辑, 我们只开了 3 个线程, 模拟 3 台机器(假如每台机器只有一个线程):
// 测试任务分片
func FragmentTest(fragmentTask *redis.FragmentTask) {
// 开启 3 个线程(模拟 3 台机器), 去处理任务
for i := 0; i <= 2; i ++ {
go fragmentTask.DoProcess()
}
// 避免子线程退出, 主线程睡一会
time.Sleep(time.Second * 100)
}
func main() {
redisLock := &redis.RedisLock{}
order := &db.Order{}
fragmentTask := &redis.FragmentTask{}
// 初始化资源
redisLock.IntiRedis()
order.InitDb()
// 测试任务分片
FragmentTest(fragmentTask)
return
}
我们先看看 DB 执行前数据, 初始状态 status 都是 0, 然后 order_id 是主键:
mysql> select * from tb_order;
+----+----------+--------------+--------+
| id | order_id | product_name | status |
+----+----------+--------------+--------+
| 1 | 1 | 鼠标 1 | 0 |
| 2 | 2 | 鼠标 2 | 0 |
| 3 | 3 | 鼠标 3 | 0 |
| 4 | 4 | 鼠标 4 | 0 |
| 5 | 5 | 鼠标 5 | 0 |
| 6 | 6 | 鼠标 6 | 0 |
| 7 | 7 | 鼠标 7 | 0 |
| 8 | 8 | 鼠标 8 | 0 |
| 9 | 9 | 鼠标 9 | 0 |
| 10 | 10 | 鼠标 10 | 0 |
| 11 | 11 | 鼠标 11 | 0 |
| 12 | 12 | 鼠标 12 | 0 |
| 13 | 13 | 鼠标 13 | 0 |
| 14 | 14 | 鼠标 14 | 0 |
| 15 | 15 | 鼠标 15 | 0 |
| 16 | 16 | 鼠标 16 | 0 |
| 17 | 17 | 鼠标 17 | 0 |
| 18 | 18 | 鼠标 18 | 0 |
| 19 | 19 | 鼠标 19 | 0 |
| 20 | 20 | 鼠标 20 | 0 |
+----+----------+--------------+--------+
直接看执行结果:
Get lock key success, processId:0, tokenId:1
Get lock key success, processId:1, tokenId:2
Get lock key success, processId:2, tokenId:3
Begin to process, processId:0, tokenId:1, print orderList:[{1 1 鼠标 1 0} {11 11 鼠标 11 0}]
Begin to process, processId:2, tokenId:3, print orderList:[{3 3 鼠标 3 0} {13 13 鼠标 13 0}]
Begin to process, processId:1, tokenId:2, print orderList:[{2 2 鼠标 2 0} {12 12 鼠标 12 0}]
Get lock key success, processId:0, tokenId:4
Begin to process, processId:0, tokenId:4, print orderList:[{4 4 鼠标 4 0} {14 14 鼠标 14 0}]
Get lock key success, processId:1, tokenId:5
Begin to process, processId:1, tokenId:5, print orderList:[{5 5 鼠标 5 0} {15 15 鼠标 15 0}]
Get lock key success, processId:2, tokenId:6
Begin to process, processId:2, tokenId:6, print orderList:[{6 6 鼠标 6 0} {16 16 鼠标 16 0}]
Get lock key success, processId:0, tokenId:7
Begin to process, processId:0, tokenId:7, print orderList:[{7 7 鼠标 7 0} {17 17 鼠标 17 0}]
Get lock key success, processId:1, tokenId:8
Begin to process, processId:1, tokenId:8, print orderList:[{8 8 鼠标 8 0} {18 18 鼠标 18 0}]
Get lock key success, processId:2, tokenId:9
Begin to process, processId:2, tokenId:9, print orderList:[{9 9 鼠标 9 0} {19 19 鼠标 19 0}]
Get lock key success, processId:0, tokenId:0
Begin to process, processId:0, tokenId:0, print orderList:[{10 10 鼠标 10 0} {20 20 鼠标 20 0}]
Get lock key success, processId:1, tokenId:1
Begin to process, processId:1, tokenId:1, print orderList:[]
Get lock key success, processId:2, tokenId:2
Begin to process, processId:2, tokenId:2, print orderList:[]
Get lock key success, processId:0, tokenId:3
Get lock key success, processId:1, tokenId:4
Begin to process, processId:1, tokenId:4, print orderList:[]
Get lock key success, processId:2, tokenId:5
Begin to process, processId:0, tokenId:3, print orderList:[]
Begin to process, processId:2, tokenId:5, print orderList:[]
我们简单分析一下, 我们每次从 DB 获取数据, 设置的 PageSize=2, 所以每个线程每次从 DB 会获取 2 条数据, 比如 tokenId=7 的线程, 会从 DB 拿到 [{7 7 鼠标 7 0} {17 17 鼠标 17 0}] 这两条数据, 这两条数据的 order_id 分别为 7 和 17, 因为我们的是把数据分成了 10 分, 所以是通过 10 取的模, 取模值和 tokenId 相等。通过上面的输出, 我们可以很清晰看到, 线程 0 拿到 0、1、4、7 这 4 个令牌, 线程 1 拿到 2、5、8 这 3 个令牌, 线程 2 拿到 3、6、9 这 3 个令牌, 3 个线程拿到的令牌互不冲突, 最后从 DB 查询的数据也不会冲突。我们把 PageSize 设置为 1, 再看看执行效果:
Get lock key success, processId:0, tokenId:9
Get lock key success, processId:1, tokenId:0
Get lock key success, processId:2, tokenId:1
Begin to process, processId:0, tokenId:9, print orderList:[{9 9 鼠标 9 0}]
Begin to process, processId:2, tokenId:1, print orderList:[{1 1 鼠标 1 0}]
Begin to process, processId:1, tokenId:0, print orderList:[{10 10 鼠标 10 0}]
Get lock key success, processId:0, tokenId:2
Begin to process, processId:0, tokenId:2, print orderList:[{2 2 鼠标 2 0}]
Get lock key success, processId:1, tokenId:3
Begin to process, processId:1, tokenId:3, print orderList:[{3 3 鼠标 3 0}]
Get lock key success, processId:2, tokenId:4
Begin to process, processId:2, tokenId:4, print orderList:[{4 4 鼠标 4 0}]
Get lock key success, processId:0, tokenId:5
Begin to process, processId:0, tokenId:5, print orderList:[{5 5 鼠标 5 0}]
Get lock key success, processId:1, tokenId:6
Begin to process, processId:1, tokenId:6, print orderList:[{6 6 鼠标 6 0}]
Get lock key success, processId:2, tokenId:7
Begin to process, processId:2, tokenId:7, print orderList:[{7 7 鼠标 7 0}]
Get lock key success, processId:0, tokenId:8
Get lock key success, processId:1, tokenId:9
Begin to process, processId:0, tokenId:8, print orderList:[{8 8 鼠标 8 0}]
Begin to process, processId:1, tokenId:9, print orderList:[{19 19 鼠标 19 0}]
Get lock key success, processId:2, tokenId:0
Begin to process, processId:2, tokenId:0, print orderList:[{20 20 鼠标 20 0}]
Get lock key success, processId:0, tokenId:1
Get lock key success, processId:1, tokenId:2
Begin to process, processId:0, tokenId:1, print orderList:[{11 11 鼠标 11 0}]
Begin to process, processId:1, tokenId:2, print orderList:[{12 12 鼠标 12 0}]
Get lock key success, processId:2, tokenId:3
Begin to process, processId:2, tokenId:3, print orderList:[{13 13 鼠标 13 0}]
Get lock key success, processId:0, tokenId:4
Get lock key success, processId:1, tokenId:5
Begin to process, processId:0, tokenId:4, print orderList:[{14 14 鼠标 14 0}]
Begin to process, processId:1, tokenId:5, print orderList:[{15 15 鼠标 15 0}]
Get lock key success, processId:2, tokenId:6
Begin to process, processId:2, tokenId:6, print orderList:[{16 16 鼠标 16 0}]
Get lock key success, processId:0, tokenId:7
Get lock key success, processId:1, tokenId:8
Begin to process, processId:0, tokenId:7, print orderList:[{17 17 鼠标 17 0}]
Begin to process, processId:1, tokenId:8, print orderList:[{18 18 鼠标 18 0}]
Get lock key success, processId:2, tokenId:9
Begin to process, processId:2, tokenId:9, print orderList:[]
看到这里, 是不是很有意思, 你也可以动手实现一下~~
这个多机分片, 并不是所有的异步任务都可以使用这种方式, 只有特点的场景才可以:
从限流常用方式, 再到 Redis 分布式锁, 最后是多机执行异步任务, 终于把这块想写的都写完了, 其实对外输出的过程, 也是自己成长的过程。最近在看消息队列和设计模式, 消息队列的理论部分已经写完了, 前后整理了一个月, 感觉头都大了, 所以纯理论的内容我会尽量少些, 多写一些实战方面的内容, 避免自己眼高手低。设计模式的部分, 我打算以实际项目为主, 然后去讲解常用设计模式的实现姿势。