在维护电商项目的时候,产品大大突然提出做一个限时特价的功能,简单4个字概括了他的需求(Excuse me?能说清楚点吗?),相信很多做电商网站的程序猿都会遇到这样的需求。
一、理解需求:
限时特价!某个时间区段内,在起始时间点去做一件事,在终止时间点去做另一件事,最后事物恢复为最初始的状态(即和之前相比没有产生变化)。也就是说,站在用户的角度,在起始时间点用户看到产品的价格产生了变化,在终止时间点看到产品恢复了原价。概括一下,在某个时间段里产品价格不同,过后产品恢复价格。
二、确定方案:
集合还是独立。集合就是建立一条特价任务,把产品加入到任务里,这样做方便管理(例如手机有3种颜色,都需要做降价处理),不用特意弄3条特价任务,但价格是一样的。独立就是单独为每件产品新建一条特价任务,这样做灵活性较大,自由分配价格(例如手机有3种颜色,只有一种颜色需要做降价处理)。两种方案各有优劣,根据具体的业务情况选择更好方案。
另外,还有一个问题,就是需不需要改变产品的价格。
- 即限时特价生效期间,我们直接改动了产品的价格,失效后再恢复成原价格。这种方法非常的简单粗暴,可能会有人提问:生效期间我们又去改产品的价格怎么办?产品是不是又变成了不同的价格呢?这个问题很简单,在产品表中加入“is_limit_time”字段,用来表示是否为限时特价生效期间(生效期间不允许更改价格)。或者又有人会问,这种做法可以吗?答案是根据实际业务情况,一般的电商项目都会有“产品价格变动记录表”,每次价格发生变动都会被记录在此表中,所以这种做法是被允许的。
- 另一种就是不直接改变产品的价格。给用户显示价格之前我们先确定这件产品的“is_limit_time”字段是否为1,如果为1,则去找最近的一条特价任务,如果存在并且处于生效期间,就直接把特价任务里的价格返回给用户,否则返回原价格。这种做法就需要在业务逻辑中用代码去实现了。
可选方案:1、集合+改变价格(难度大); 2、集合+不改变价格(难度最大); 3、独立+改变价格(难度小); 4、独立+不改变价格(难度适中)。
最终方案:根据实际的业务情况,我最终选择了第4种方案。难度适中。
三、解决需求:
为了更高效的去完成这个需求,我们使用Redis的键过期事件来实现限时特价的功能。(虽然使用crontab定时任务也可以,但是不推荐这种做法,麻烦,管理不方便,数据量大时性能差。)
1、创建数据表:
首先我们先建立两张数据表:tb_product表和tb_product_price_mission表,即产品表和产品价格任务表。
// 到项目根目录执行以下2条命令
php artisan make:migration create_tb_product_table
php artisan make:migration create_tb_product_price_mission_table
分别修改 database/migrations 的 create_tb_product_table.php 和 create_tb_product_price_mission_table.php 中的 up 方法:
// create_tb_product_table.php
public function up()
{
Schema::create('tb_product', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->default('')->comment('产品名称');
$table->decimal('price', 8, 2)->default(0)->comment('价格');
$table->tinyInteger('is_limit_time')->default(0)->comment('限时特价是否生效');
$table->tinyInteger('is_deleted')->default(0)->comment('删除逻辑标识符');
$table->integer('created_time')->default(0)->comment('创建时间');
$table->integer('updated_time')->default(0)->comment('更新时间');
});
}
// create_tb_product_price_mission_table.php
public function up()
{
Schema::create('tb_product_price_mission', function (Blueprint $table) {
$table->increments('id');
$table->integer('product_id')->default(0)->comment('产品ID');
$table->decimal('mission_price', 8, 2)->default(0)->comment('任务价格');
$table->tinyInteger('is_happened')->default(0)->comment('是否生效:-1已失效,0未生效,1生效中');
$table->integer('start_time')->default(0)->comment('开始时间');
$table->integer('end_time')->default(0)->comment('结束时间');
$table->integer('created_time')->default(0)->comment('创建时间');
});
}
执行migrate命令创建数据表:
php artisan migrate
至此,两张数据表就已经创建完成了。
2、配置Redis
先给Laravel安装Redis的扩展:
composer require predis/predis
修改Redis的配置文件,启动键过期事件通知:
notify-keyspace-events "Ex"
重启Redis,在 .env 文件中配置Redis的相关信息:
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_RW_TIMEOUT=-1
修改 config\database.php 中Redis的配置:
'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', -1), // 读写超时设定
],
],
3、代码实现:
先创建任务文件 ProductPriceMission.php :
php artisan make:command ProdcutPriceMission
我们监听“price_mission_start_time:product_id-mission_id”和 “price_mission_end_time:product_id-mission_id” 两个键的过期事件,修改 app\Console\Commands\ProductPriceMission.php :
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class ProductPriceMission extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'product_price:mission';
/**
* The console command description.
*
* @var string
*/
protected $description = '监听特价任务创建,开始时间到了使任务生效,结束时间到了使任务失效';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$db = config('database.redis.default.database', 0); // 监听的数据库
$pattern = '__keyevent@'. $db . '__:expired'; // 监听事件:键过期事件
Redis::subscribe([$pattern], function ($channel) { // 订阅键过期事件,$channel为返回的键名
$key_type = str_before($channel,':');
switch ($key_type) {
case 'price_mission_start_time':
$product_id = str_before(str_after($channel,':'), '-'); // 取出产品 ID
$mission_id = str_after(str_after($channel,':'), '-'); // 取出任务 ID
$product = DB::table('product')->find($product_id);
$mission = DB::table('product_price_mission')->find($mission_id);
if ($product && $mission) {
// 这里我们直接修改数据库
$result_product = DB::table('product')->where('id', $product_id)->update(['is_limit_time'=>1]);
$result_mission = DB::table('product_price_mission')->where('id', $mission_id)->update(['is_happened'=>1]);
Log::info('产品ID:' . $product_id . ':' . $result_product ? '生效成功' : '生效失败');
// 如果业务量大的话,我们可以把生效的操作放到队列中去执行
// Job::dispatch($product_id, $mission_id, $is_limit_time = 1, $is_happened = 1);
}
break;
case 'price_mission_end_time':
$product_id = str_before(str_after($channel,':'), '-'); // 取出产品 ID
$mission_id = str_after(str_after($channel,':'), '-'); // 取出任务 ID
$product = DB::table('product')->find($product_id);
$mission = DB::table('product_price_mission')->find($mission_id);
if ($product && $mission) {
// 这里我们直接修改数据库
$result_product = DB::table('product')->where('id', $product_id)->update(['is_limit_time'=>0]);
$result_mission = DB::table('product_price_mission')->where('id', $mission_id)->update(['is_happened'=>-1]);
Log::info('产品ID:' . $product_id . ':' . $result_product ? '失效成功' : '失效失败');
// 如果业务量大的话,我们可以把失效的操作放到队列中去执行
// Job::dispatch($product_id, $mission_id, $is_limit_time = 0, $is_happened = -1);
}
break;
default:
break;
}
});
}
}
之后我们用 supervisor 进行管理就行了,让它用守护进程在后台执行,或者直接在项目根目录执行以下命令也可以:
php artisan product_price:mission &
4、测试
插入数据:
public function insert()
{
$time = time();
$product_data = [
'name' => '测试产品',
'price' => '10',
'created_time' => $time,
'end_time' => $time
];
$product_id = DB::table('product')->insertGetId($product_data);
if ($product_id) {
echo '产品ID:'. $product_id . "插入成功\n";
}
$mission_data = [
'product_id' => $product_id,
'mission_price' => '5',
'start_time' => $time + 60, // 一分钟后开始生效
'end_time' => $time + 120, // 两分钟后失效,即生效时间为60秒
'created_time' => $time
];
$mission_id = DB::table('product_price_mission')->insertGetId($mission_data);
if ($mission_id) {
echo '任务ID:' . $mission_id . "插入成功";
}
//设置键的过期时间
Redis::set('price_mission_start_time:'.$product_id.'-'.$mission_id, $product_id.'-'.$mission_id, 'EX', 60);
Redis::set('price_mission_end_time:'.$product_id.'-'.$mission_id, $product_id.'-'.$mission_id, 'EX', 120);
}
查看产品:
public function product()
{
$time = time();
$product_id = 1;
$product = DB::table('product')->find($product_id);
// 如果is_limit_time为true,查找该产品正处于生效期间的任务价格
if ($product && $product->is_limit_time) {
$mission_price = DB::table('product_price_mission')
->where('product_id', $product_id)
->where('is_happened', 1)
->where('start_time', '<=', $time)
->where('end_time', '>', $time)
->value('mission_price');
$product->price = $mission_price;
}
dd($product);
}
输入:http://laravel.localhost/insert :
查看产品数据, 输入:http://laravel.localhost/product :
过一分钟,我们再刷新查看一下:
再过一分钟,限时特价应该失效了,看看是不是恢复为原价:
最后,我们查看一下日志文件,在 /storage/logs/laravel.log :
大家对比下两个时间,是不是刚好只有60秒的生效时间。
5、总结
实际上真正的业务情况比上述的还要复杂,大家可以根据自己实际遇到的情况调整思路。上述办法主要使用Redis的键过期事件来实现,在新增特价任务时就设置了两个key的过期时间,一个负责生效,另一个负责失效,用户每次查看产品数据时都要去检查“is_limit_time”是否为1(即是否在限时特价生效期间,假如不想在产品表加入“is_limit_time”字段,使用缓存也行),如果为1才去产品价格任务表中查找任务价格,否则返回原价。思路比较简单,感觉不太高效,但又想不到更好的方案出来了,呜呜呜~