PLAN A:30 分钟未付款取消订单

周日有个妹子加我,附言上写着

听说你那方面很厉害

听说? 你要是听说我搬砖厉害,我这小身板一看就是吹牛,至于那方面,你问下我们班的女生,当仁不让呀,要不你到隔壁护理学院问问,那里仍有我的传说。

第一次亲密接触

马上她发过来一条消息

舞飞杨:小哥哥,先问你个事,我这边有个需求,用户下单后30分钟如果没付款就取消掉,这个要怎么写呀。

qufo:这个还不简单,写个取消订单的命令,弄个计划任务定时不就行了。

舞飞杨:哦,就是 crontab ?

qufo: 是呀,follow me

先来个

$php artisan make:command OrderCancel
Console command created successfully.

然后修改 app\Console\Commands\OrderCancel.php 为如下:

<?php

namespace App\Console\Commands;

use App\Http\Models\Order;
use Illuminate\Console\Command;

class OrderCancel extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'order:cancel';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '30分钟未付款取消订单';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     * @throws
     */
    public function handle()
    {
        try {
            $unPaid = Order::where('created','<',time()-30*60) //创建时间在30分钟以前
            ->where('order_status',1) // 刚下单未支付
            ->get();
            foreach ($unPaid as $order) {
                $order->cancel(); // 执行取消动作
            }
        } catch (\Exception $e) {
            throw $e;
        }
        return true;
    }
}

试一下在项目根下执行 php artisan list 应该能看到下面那一行了。

 order
  order:cancel         30分钟未付款取消订单

直接执行命令 php artisan order:cancel 即可测试本地取消订单。

舞飞杨:嗯,按你写的还真可以了,现在用命令可以取消订单了。
舞飞杨:可是,我不能整天坐在这里打命令吧?
qufo:这个好说,我们弄个计划任务。

执行系统命令 crontab -e ,在里面加入

* * * * * cd /项目的根目录 && php artisan schedule:run >> /dev/null 2>&1 

然后 app\Console\Kernel.phpschedule 方法里,加入下面一行:

$schedule->command('order:cancel')->everyMinute();

这样,取消订单就会每分钟自动执行一次了,省事了。

舞飞杨:我去试一下。
舞飞杨:哎,还真好了,谢谢。
qufo: 对了,你从哪知道我那方面很厉害的,要不要改天试一下?
qufo:在么?
qufo:在么?

代志大条了

静静过了两天,飞杨倒是没怎么找我,我找她也不回,哎,这人啦,她的问题解决了我的问题怎么办。
刚想关电脑休息,她的消息又来了。

舞飞杨:上次那个计划任务真不错,可以自动执行,可是近来订单增多,经常是前一个任务还没执行完下一个任务又开始启动了,然后锁着表改不了数据更惨了。

qufo: 那是,业务量小的时候这个方案好用方便,可是业务量大了,重入会出问题;而且定时任务涉及到 crontab 的权限控制问题。订单量大一点就不好用了。而且,因为我们的任务每分钟执行一次,所以有些订单会在30分钟的时候执行取消,有些会在接近31分的时候执行。就算没订单,一天也重复执行 1440 次。随着业务的扩展,除了取消订单,还会有提醒支付,催商家发货,催用户确认收货,催骑手接单等等一堆事情,这些加进去,计划任务越来越庸肿,执行效率大大降低,搞不好容易出大事。

舞飞杨:对呀对呀,现在的计划任务已经有20多个了,再加进去不是办法呀。之前的任务现在执行得乱78糟,全乱套了。现在还有什么好办法么?

qufo:有倒是有,不过我需要你有用过一样东西。

舞飞杨:你要什么?流氓。

qufo: 什么流氓,我说要用 redis

舞飞杨:哦,我知道,我装过,用过一阵子,不过,这有什么关系?

qufo:在订单确认成功之后,往 redis 里加入key, 用 ORDER_CONFIRM:订单ID 这样的格式来,然后定义他30分钟后过期,我们监听这个键过期事件就好了。

先保证 redis 的版本大于 2.8 ,现在绝大部分不成问题了,然后修改 redis 的配置文件,加入

notify-keyspace-events "Ex"

以启用键过期的通知。
然后重新启动 redis

.env 里,确认 CACHE_DRIVER=redis ,并配置好相应的服务地址,密码之类的。
然后,在控制器中,处理好订单确认写入数据库后,增加一行

Cache::store('redis')->put('ORDER_CONFIRM:'.$order->id,$order->id,30); // 30分钟后过期--执行取消订单

然后我们来监听 ORDER_CONFIRM:ORDER_ID 的过期事件
先建个命令,我们一会儿的监听全靠他了。

$php artisan make:command OrderExpireListen
Console command created successfully.

然后把命令执行文件 app\Console\Commands\OrderExpireListen.php 写成这样:

<?php

namespace App\Console\Commands;

use App\Http\Models\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis as Redis;

class OrderExpireListen extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'order:expire';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = '监听订单创建,在30分钟后如果没付款取消订单。';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        //
        $cachedb = config('database.redis.cache.database',0);
        $pattern = '__keyevent@'.$cachedb.'__:expired';
        Redis::subscribe([$pattern],function ($channel){     // 订阅键过期事件
            $key_type = str_before($channel,':');
            switch ($key_type) {
                case 'ORDER_CONFIRM':
                    $order_id = str_after($channel,':');    // 取出订单 ID
                    $order = Order::find($order_id);
                    if ($order) {
                        $order->cancel(); // 执行取消操作
                    }
                    break;
                case 'ORDER_OTHEREVENT':
                    break;
                default:
                    break;
            }
        });
    }
}

文件好了之后,使用

$php artisan order:expire

启动,就可以了。

舞飞杨:我去试试。

舞飞杨:enn,好了。可是才用了几分钟,自动断掉了。

qufo: 是呀,redis 的默认连接是有超时的。

你改下 app\config\database.phpredis 节,增加一个 read_write_timeout :

    'redis' => [

        'client' => 'predis',

        'default' => [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_DB', 0),
            'read_write_timeout' => env('REDIS_RW_TIMEOUT', 5),  // 读写超时设定
        ],
    ],

然后在 .env 中配置 REDIS_RW_TIMEOUT=-1 这样就不会超时了。

舞飞杨:哦,我知道了,我去试试。

舞飞杨:嗯,真好了,可是这个要放入后台执行的,命令行一直停在那里不好吧。

qufo: 这个,用 supervisor 就好了,你们的运维会弄这个。如果运维不懂弄的话,直接运行 php artisan order:expire & 也行。

舞飞杨:这样也行么,我去试下。

qufo: 这个方案的好处在于不需要计划任务了,不会有大的时间偏差,而且我们可以定义各种键的名称,各自监听各种键的过期事件,集中管理,多好。

qufo: 嗯。实在不行,告诉我位置,我过去帮你弄呀。你在哪儿呀?

qufo: 最近有几部新电影,要不要一起去看。

qufo: 你在哪呀?

qufo: 在么?

要上市吗

又失踪了几周。我都要心如死灰了。
声音再次响起。

舞飞杨:小哥哥,上次的东西真好,我把计划任务全改成那个了,好用,而且时间准,互不影响,

qufo: 嗯。

舞飞杨:可是我们的业务增长很快,一台机器处理不了,已经组了应用群集了,每台机器上都要装 redis 吗?

qufo: 嗯。

舞飞杨:不是吧,那么多 redis 服务器一台一个,能集中处理吗?所有的应用都把键存到一台机器上,然后只要一份监听程序监听那个过期事件?

qufo: 嗯。

舞飞杨:我听说你很厉害才找你。要是一台监听处理的机器处理来不及,再加一台去处理吗?

qufo: 嗯。

舞飞杨:嗯什么嗯,是你不知道吧?!

qufo: 什么叫不知道,当业务量大起来的时候,直接增加监听处理的机器是不行的,他们监听同一个过期事件,两台机器会同时接到过期事件,除非进行 hash 分工,要不然处理两遍事件就傻了。业务量足够大的时候,得用消息队列了。

舞飞杨:哦,消息队列怎么用?

qufo: 上次的监听处理程序只要一台处理,把监听处理的过程改一下,取出订单ID之后不要去处理,通过 rpush 放到一个 redis 的队列里去。另外起几台服务器,连到这个 redis 服务器,通过 blpop 接收消息队列里出来的订单ID。这样,多台机器可以同时工作,一个订单只会从 blpop 里出来一次,不会重复执行,多台机器可以分担任务,又互不影响。消息队列也可以换成业界成熟的 rabbitmqkafka 之类的专业消息队列,那又是另外一个话题了。反正业务量大了,变复杂了,消息总线跑不掉,天猫京东也差不多如此。

舞飞杨:京东我知道你去过,可是京东是 .net 的技术栈,天猫你又没去过,你怎么知道。

握草,她怎么知道我去过京东没去过天猫,我赶紧重新看了一下她的个人资料,介绍不知道什么时候变成了“老杨他妹”,不对呀,我记得老杨跟我说过他是家中独子呀。老杨那个160斤的月半子竟然玩 cos ,难怪人家叫“轻舞飞杨”,她叫“舞飞杨”,还能舞得起来吗?
我了个去。
我抄起手机拨通了老杨的电话。
电话还没拿到耳边, 就听到那边传来一阵猪叫声,伴随着塑料凳子压塌的声音,一屁股蹲啪的声音,手机掉地上的biaji声。


本帖由系统于 5个月前 自动加精
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 58

666 不写小说可惜了

5个月前 评论

@Blings 第三个也简单。按文中描述的,把 app\Console\Commands\OrderExpireListen.php 里的 $order->cancel(); // 执行取消操作 换成 Redis::rpush('ORDER_CANCEL',$order->id); 这样放到队列里。另写一个命令,象 OrderExpire 一样监听队列,从 Redis::blpop 里取出,就有订单ID,然后按往常一样 cancel 处理。上述是伪码。

因为大部分人的问题可以通过第二种方案解决,若业务量真的大到需要多台单独的机器来处理,估计项目已经用其他语言重构了,所以只留了思路,没有代码。

5个月前 评论
AlicFeng

O(∩_∩)O哈哈~看着你的故事,畅饮了杯~

5个月前 评论
努力做个技术男

有趣有趣

5个月前 评论
EDISONYANG

无聊 俗气 ...

5个月前 评论
zxinxin

有意思

5个月前 评论

666 不写小说可惜了

5个月前 评论
gangpula

有意思

5个月前 评论

哈哈,同样的需求我之前只也做到使用第二个方法实现,第三个要如何实践啊

5个月前 评论

@Blings 第三个也简单。按文中描述的,把 app\Console\Commands\OrderExpireListen.php 里的 $order->cancel(); // 执行取消操作 换成 Redis::rpush('ORDER_CANCEL',$order->id); 这样放到队列里。另写一个命令,象 OrderExpire 一样监听队列,从 Redis::blpop 里取出,就有订单ID,然后按往常一样 cancel 处理。上述是伪码。

因为大部分人的问题可以通过第二种方案解决,若业务量真的大到需要多台单独的机器来处理,估计项目已经用其他语言重构了,所以只留了思路,没有代码。

5个月前 评论

@qufo 对集群还是不熟悉,先看一看,不过思路大致明白了

5个月前 评论

延迟队列了解一下

5个月前 评论
ruke

用mq来做代替redis--driver, 起一个专门的队列, 下单的时候加入一个延时事件

可以多起几个消费者跑起来

5个月前 评论

看到标题的第一反应就是用队列delay30分钟检查订单的状态就好了

5个月前 评论

@赵亚博 部分延迟队列也是使用的过期,只不过内部封装好了。

5个月前 评论

@ruke 是的,第三种方案就是这种。

5个月前 评论

@hello-bug 是的。Laravel 里直接有了 delay .

5个月前 评论

考虑下要不要把方案三也简单地实现下。

5个月前 评论

写的很好,幽默风趣很有才

5个月前 评论
納末

这故事编的可以,最主要的是点也讲述的听明白

5个月前 评论
XiaohuiLam

为什么不用延时队列?

4个月前 评论
aen233

哎呀,一开头就是开车呀

4个月前 评论

不愧是技术大佬啊,讲的很好啊,还附带一点幽默的故事情节。

4个月前 评论
__中国人

写的很棒

4个月前 评论

太流氓了!!

4个月前 评论
黑将军

已经到了超速的边缘了,不过还好,我还没翻车

4个月前 评论

一波猝不及防的飙车! 666 点赞!
再咨询一下,对于时间跨度比较大的,比如一年后才会触发的这种定时任务,有没有好的方案? 感谢

4个月前 评论

其实延时队列本身没什么问题,只是既然使用队列,那么一般发送之后不考虑取消的,如果是这样的话,一旦发送,没办法变更,在一个每日数十万甚至百万以上订单系统,这是一个灾难吧

4个月前 评论

@snower 没事的,别放 redis 里,放其他专业的消息队列里,一天百万不是什么事儿。象 rabbitmq , zeromq ,kafka ,至少是 c10k 的,每秒万条并发,一天下来10亿不是事。至于处理那些过期事件,要看消费者的能力了,多台群集可以尝试。如果担心一台 mq 的处理能力,可以配置多台,根据类型发到不同的消息队列分担任务就行。

4个月前 评论

该死的标题党。

4个月前 评论
Aolinver

段子手,技术还讲的这么好

4个月前 评论
GDDD

我也想问 为什么不用延时队列?

4个月前 评论

@GDDD Laravel 里有 delay ,可以直接用,因为有很多讲 delay 的文章,想必大家都熟悉了,所以这次就略过了。

4个月前 评论
MrJing

好了,我以后面试不问这个问题了,手动狗头

4个月前 评论

老哥666

4个月前 评论
YLR

技术很厉害,没想到还是个段子手啊!

4个月前 评论
guanhui07

有意思..

3个月前 评论

看你这篇文章使我有种看《大话设计模式》里对白的感觉。。

3个月前 评论

保证不重复就用队列。

3个月前 评论

这个写文章的方式挺好的,讲解的非常清晰,剧情引人入胜。

3个月前 评论
xiaofeng94

有意思;牛逼,点个赞

3个月前 评论
BradStevens

redis zset 维护也不错,保存订单id,然后用订单的创建时间戳当成 score,订单完成的时候,就 zrem key orderid, 其他定时任务查某个时间区间的数据,zrangebyscore ,数据占用少到极致,甚至都不多查一遍数据库

3个月前 评论

@BradStevens 有点不解

  • 放在 zset 中的时候是不是仍然需要定时 (如1s或1分钟) 去查询 redis 。这是不是又回到了第一个问题?查询 redis 应该会比查询数据库(Mysql)效率高些。
  • 至于少查一遍数据库的话,任务都是不查数据库的。只是任务的执行体里要查库以确定订单实体,看订单的状态是否变更了是否需要继续执行,这是执行逻辑的事了。即便把订单序死化放到 redis 里,仍然可能在真正执行定时操作时订单状态发生了改变。
2个月前 评论
BradStevens

@qufo 使用zset 比利用 redis的大key set这样的形式,节约一些空间占用,定时任务处理方面,可以使用swoole的swoole_timer_tick 全都是内存级的操作,会提升很多效率。参考这个文章:https://alpha2016.github.io/2019/02/24/%E5...

2个月前 评论

@BradStevens

  • 上文中提到的是队列 加 过期,key 是 ORDER_CONFIRM.$order->id 并不大, value 是 order_id 本身,不是序列化的订单内容。这个 value 甚至可以设为 1 ,并不影响。对于存储空间来说,差异不大。
  • 参考的文章我看了一下,同样引入了 redis ,不过不采用 crontab 来做定时任务,而是使用 swoole ,这样一来,引入了新的复杂性。
  • swoole 没有细用,但看其文档,swoole_timer_tick 限制比较多。
2个月前 评论
BradStevens

@qufo

  • 存储空间上差别不会很大,看楼主的方式是每个 orderid 都存储,然后监听订阅过期事件,如果订单多的情况下,每次的过期操作 --- 查询,修改订单状态的操作也是很费时的,即使很快也会增加一些数据库读写(订单相关的操作一般情况下会涉及到很多事务)。对比 zset 这种存储,而用户端完成订单的时候 zrem 对应的 orderid, 这样每次查询需要过期的订单 id 数据集,会得到一个数组,一条查询条件为 in 的 update 就可以搞定,减少大量的读写操作。
  • 复杂性方面,如果选择用 swoole 做更多事情,发挥他的优势,相对来说复杂性不是很高了。
  • 延伸问题:如果一个 crontab 会触发设置的多个 laravel 的事件,其中一个任务没执行完毕,而下一分钟又该定时触发了,这种情况在真实情况下也是需要考虑的,swoole 的定时器,自动处理了这个问题,可以注意一下这个细节的。

个人观点还是我的那种方法其实更简单,更高效率,而且提前避免一些小细节。

2个月前 评论

已经将配置文件改成notify-keyspace-events "Ex" ,key过期之后没有任何反应啊?还是说我哪里写错了……,
单独使用
Redis::publish('test-channel', json_encode(['foo' => 'bar']));

Redis::subscribe(['test-channel'], function ($message) {
$this->info($message);
});

这是可以收到消息的

2个月前 评论

@james_xue 确认下版本,是否重启过服务。然后 redis 命令形式订阅下过期消息看能不能收到。

2个月前 评论

很用心,支持

2个月前 评论
笑逐颜凯

make 很棒 写的超好呀

2个月前 评论

这是个令人不太愉快的故事,
但是个看完令人愉快的技术贴。

如此可以说明,对女人再好人家也对你爱理不理。相对的,对男同胞友人好点,就记住你了 :smirk:

2个月前 评论

@命中水 小男孩在外面要保护好自己,不要被别的小男孩骗了。

2个月前 评论

@zqiqicn 那不会,如果真要被骗了,我要让他知道我那方面的厉害

2个月前 评论

Predis\Response\ServerException : ERR only (P)SUBSCRIBE / (P)UNSUBSCRIBE / PING / QUIT allowed in this context

这个是什么问题

1个月前 评论

全看聊天记录了,我点进来是干嘛来着???

1个月前 评论

如果不是你写故事,我都不一定看得完。

4周前 评论

@james_xue 我遇到的问题跟你一样,我还尝试在cli中更改这个参数,但是按照他的说法或者官网的文档,都是监听不到过期。

3周前 评论

@wells 你将代码放在服务器试试,我的本地一直跑不通,放在服务器上可以的

3周前 评论

@james_xue 我就是在虚拟机里面搞得,监听不到事件,但是命令行里能监听。然后也自动退出

3周前 评论

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!