订单自动关闭方案设计
订单自动关闭本质上是一类延时任务如何处理的问题,具体的场景可能有:
- 订单超时未支付自动关闭
- 自动确认收货
- 社交平台定时发布
- 超时未取件自动退回
- 用户注销15天后自动删除
方案1:定时任务扫表
建立一个 cronjob 每隔一段时间扫一次表,查询所有到期的订单,执行关单操作。
问题:
- 时间不精准:定时任务是基于固定的频率执行的,如果要保证精准度就要增加频率,不易控制
- 无法处理大数据量:数据量大会导致任务执行时间长,订单被扫描到的时间会延后
- 数据库压力: 数据库 IO 在短时间内被大量占用和消耗
- 分库分表问题:如果有分库分表场景,进行全表扫描效率很低
- 分布式问题:多个节点到同一时间同时查询数据库,需要额外的分布式锁设计
这种方案适用于业务简单、时间精度要求不高、不需考虑分布式的场景
方案2:时间轮
关于时间轮的介绍可以看:【时间轮】TimeWheel原理:实现高效任务管理-CSDN博客
使用时间轮就是将所有 订单到期检查任务 分配到一个时间轮中,时间轮按照固定的时间间隔进行周期性旋转。当时间轮旋转到某个槽位时,触发该槽中对应的任务。
这种方案比定时任务性能高一些。
问题:
- 内存占用:时间轮需要为每个槽位维护一个任务队列,当任务量很大时,可能会占用较多内存
- 分布式问题:容易出现单点故障,需要设计分布式时间轮,增加主备模式、负载均衡算法,设计复杂
- 高并发场景:大量订单同时到期,导致时间轮需要在短时间内处理大量任务,引发性能瓶颈
方案3:延迟队列
利用消息队列组件的延迟队列特性实现,如以下几种消息队列组件:
- rabbitmq:使用死信队列 或 使用插件
- rocketmq:原生支持延迟队列,但只能设置固定级别的延迟时间
- kafka:生产者将到期时间放入消息,消费者消费时检查是否到期,未到期则放回队列
-
redis stream:生产者将到期时间放入消息,消费者消费时检查是否到期,未到期则放回队列
问题:
- 需要依赖消息队列组件,并且可能需要安装对应的插件
- 高并发场景下,可能出现消息积压
- 放回队列时,会出现消息顺序性问题,可能需要手动管理
- 延迟精度问题:精度依赖于 mq 的调度机制,可能存在延迟偏差
方案4:Redis 过期监听
将待关闭的订单信息存入 redis 并设置过期时间,开启 redis 的 notify-keyspace-events: Ex 配置,监听 redis key 的过期事件,接收到事件通知时关闭订单。
问题:
- 不能监听指定 key,只能监听所有 key,收到事件后再自己筛选
- 需要修改 redis 配置
- redis 不保证 key 在过期时被立即删除,更不保证这个消息被立即发出,会有消息延迟
测试代码:
func Test_RedisSubscribe(t *testing.T) {
_, err := redisCli.ConfigSet(context.Background(), "notify-keyspace-events", "Ex").Result()
if err != nil {
t.Fatal(err)
}
keyPrefix := "test:pending:"
go func() {
for i := 0; i < 3; i++ {
key := keyPrefix + uuid.New()
expire := time.Second * time.Duration(i+1)
redisCli.Set(context.Background(), key, "test", expire)
time.Sleep(time.Millisecond * 500)
t.Logf("set key %+v", key)
}
}()
# 0表示使用redis db 0
pattern := "__keyevent@0__:expired"
pubsub := redisCli.PSubscribe(context.Background(), pattern)
defer pubsub.Close()
var result int
go func() {
t.Logf("start receive %+v", pattern)
pubsub.Channel()
for msg := range pubsub.Channel() {
if !strings.HasPrefix(msg.Payload, keyPrefix) {
continue
}
t.Logf("got msg. channel: %+v, payload-->%+v", msg.Channel, msg.Payload)
result++
}
}()
time.Sleep(time.Second * 6)
assert.Equal(t, result, 3)
}
方案5:Redis ZSET 有序集合
将订单 id 作为 member,过期时间设置为 score, redis 会对 zset 自动按照 score 排序。再开启一个 redis 定时任务扫描,查询 score <= 当前时间 的条目,取出订单号进行关单操作。
问题:
- 高并发下,可能多个节点获取到同一个订单号,可以加分布式锁解决
测试代码:
func Test_RedisZSet(t *testing.T) {
key := "test:pending"
for i := 0; i < 5; i++ {
id := uuid.New()
expireT := time.Now().Add(time.Second * time.Duration(i+1))
redisCli.ZAdd(context.Background(), key, redis2.Z{
Member: id,
Score: float64(expireT.Unix()),
})
t.Logf("set key %+v %+v", key, id)
}
for {
maxScore := fmt.Sprintf("%+v", time.Now().Unix())
result := redisCli.ZRangeByScore(context.Background(), key, &redis2.ZRangeBy{Min: "0", Max: maxScore}).Val()
t.Logf("got pending: %+v", result)
if len(result) == 0 {
break
}
// 处理业务逻辑
//
// 删除ZSet中的元素
redisCli.ZRemRangeByScore(context.Background(), key, "0", maxScore)
// 获取下一个时间 (即ZSet的第一个元素)
members := redisCli.ZRangeWithScores(context.Background(), key, 0, 0).Val()
if len(members) == 0 {
t.Logf("no more pending, waiting...")
<-time.After(time.Second * 3)
continue
}
// 等待到达下一个删除时间
nextT := int64(members[0].Score)
nextV := members[0].Member.(string)
wait := nextT - time.Now().Unix()
if wait < 0 {
wait = 0
}
t.Logf("almost reach next time, waiting... nextT: %+v, nextV: %+v", time.Unix(nextT, 0), nextV)
<-time.After(time.Second * time.Duration(wait))
}
}
当然,上述代码自己写起来有些复杂,可以直接使用第三方库。
这个写得不错:github.com/HDT3213/delayqueue 一个基于 redis ZSET 实现的延时队列