├── app ├── model │ ├── .gitkeep │ ├── QuanFetch.lua │ └── QuanModel.php ├── view │ └── .gitkeep ├── config │ ├── .gitkeep │ └── myf.php ├── controller │ ├── .gitkeep │ └── Quan.php ├── service │ ├── .gitkeep │ └── QuanService.php └── webroot │ ├── .gitkeep │ └── index.php ├── common ├── config │ ├── .gitkeep │ └── myf.php ├── model │ └── .gitkeep ├── service │ └── .gitkeep └── view │ └── .gitkeep ├── composer.json ├── LICENSE └── README.md /app/model/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/view/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controller/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/service/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/webroot/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/model/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/service/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/view/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myf/app", 3 | "description": "my framework app", 4 | "type": "project", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "owenliang", 9 | "email": "120848369@qq.com" 10 | } 11 | ], 12 | "require": { 13 | "myf/core": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/webroot/index.php: -------------------------------------------------------------------------------- 1 | true, 6 | 7 | // 路由配置 8 | 'route' => [ 9 | // 静态路由 10 | 'static' => [ 11 | '/quan/fetch' => ['Quan', 'fetch'], 12 | '/quan/upload' => ['Quan', 'upload'], 13 | '/quan/test' => ['Quan', 'test'], 14 | ], 15 | // pcre正则路由 16 | 'regex' => [], 17 | ], 18 | ]; -------------------------------------------------------------------------------- /app/service/QuanService.php: -------------------------------------------------------------------------------- 1 | $batchId, 'batchSize' => $batchSize, 'coupons' => $coupons]; 27 | } 28 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 owenliang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/model/QuanFetch.lua: -------------------------------------------------------------------------------- 1 | local quan_id = tostring(KEYS[1]) 2 | local uid = tostring(ARGV[1]) 3 | 4 | -- 应答函数 5 | local function response(errno, msg, data) 6 | errno = errno or 0 7 | msg = msg or "" 8 | data = data or {} 9 | return cjson.encode({errno = errno, msg = msg, data = data}) 10 | end 11 | 12 | -- 判断用户没有抢过该优惠券 13 | local log_key = "LOG_{" .. quan_id .. "}" 14 | -- return log_key 15 | local has_fetched = redis.call("sIsMember", log_key, uid) 16 | if (has_fetched ~= 0) then 17 | return response(-1, "已经领取过") 18 | end 19 | 20 | -- 遍历优惠券所有批次 21 | local quan_key = "QUAN_{" .. quan_id .. "}" 22 | local batch_list = redis.call("hGetAll", quan_key) 23 | local result = false 24 | for batch_idx = 1, #batch_list, 2 do 25 | repeat 26 | -- 校验批次状态(是否online) 27 | local batch_info = cjson.decode(batch_list[batch_idx + 1]) 28 | if (batch_info["online"] ~= true) then 29 | break 30 | end 31 | 32 | -- 尝试从券池取出1个券码 33 | local batch_key = batch_list[batch_idx] 34 | local coupon = redis.call("zRange", batch_key, 0, 0) 35 | if (#coupon == 0) then 36 | break 37 | end 38 | coupon = coupon[1] 39 | redis.call("zRem", batch_key, coupon) 40 | 41 | -- 弹出一个券码, 标记用户已抢 42 | redis.call("sAdd", log_key, uid) 43 | 44 | -- 将券码放入异步队列 45 | result = {uid = uid, quanId = quan_id, batchKey = batch_key, coupon = coupon} 46 | redis.call("rPush", "DB_QUEUE", cjson.encode(result)) 47 | until true 48 | 49 | if result ~= false then 50 | break 51 | end 52 | end 53 | 54 | if (result == false) then 55 | return response(-1, "优惠券已抢完") 56 | else 57 | return response(0, "秒杀成功", result) 58 | end 59 | 60 | 61 | -------------------------------------------------------------------------------- /app/model/QuanModel.php: -------------------------------------------------------------------------------- 1 | evalSha($scriptSha1, [$quanId, $uid], 1); // 按quanid做路由 32 | if ($redisMaster->getLastError()) { // 如果evalsha报错, 则进行一次script load 33 | // 懒惰加载lua脚本 34 | $script = file_get_contents(__DIR__ . '/QuanFetch.lua'); 35 | 36 | if (!$redisMaster->script('load', $script)) { 37 | return false; 38 | } 39 | // 然后重试脚本 40 | $result = $redisMaster->evalSha($scriptSha1, [$quanId, $uid], 1); 41 | } 42 | 43 | $result = json_decode($result, true); 44 | return $result; 45 | } 46 | 47 | /** 48 | * 上传N个券码到批次的券码池 49 | * @param $quanId 50 | * @param $batchId 51 | * @param $coupons 52 | */ 53 | public static function uploadBatchToRedis($quanId, $batchId, $coupons) 54 | { 55 | $redisMaster = Redis::master('default'); 56 | 57 | $quanKey = "QUAN_{" . $quanId . "}"; // {quanid} redis hash tags 58 | $batchKey = "BATCH_{" . $quanId . "}_" . $batchId; // {quanid} redis hash tags 59 | 60 | 61 | $tran = $redisMaster->multi(\Redis::PIPELINE); 62 | // 券码加入集合 63 | foreach ($coupons as $coupon) { 64 | $tran->zAdd($batchKey, 0, $coupon); 65 | } 66 | // 批次加入优惠券 67 | $tran->hSet($quanKey, $batchKey, json_encode(['online' => true])); 68 | $tran->exec(); 69 | 70 | return $redisMaster->zCard($batchKey); 71 | } 72 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redis+lua优惠券秒杀demo 2 | 3 | ## 说明 4 | 5 | 本质是把库存放到redis里做扣减,解决数据库更新库存的行锁瓶颈。 6 | 7 | 库存流水需要异步插入到数据库中,作为最终的业务凭证。 8 | 9 | ## 原理 10 | 11 | * 前端:采用PHP-FPM实现,但是建议生产环境使用GO/JAVA等支持redis长连接的开发语言/框架。 12 | * 后端:利用Redis维护优惠券库存,利用Redis Lua保障库存扣减幂等性,同时投递库存流水日志到Redis队列。 13 | * 任务:消费Redis中的库存流水日志,批量插入到数据库中,落地为业务凭证。(我没实现) 14 | 15 | ## 概念 16 | 17 | * 优惠券:用户看到的优惠券实体,内部包含多个批次。 18 | * 批次:一个优惠券由多个批次组成,一个批次内有多个券码coupon。 19 | 20 | ## 业务流程 21 | 22 | ``` 23 | 1)创建优惠券 24 | 2)创建批次,上传对应的若干券码(或者随机生成) 25 | 3)将批次生效到线上,用户可以秒杀 26 | ``` 27 | 28 | ## 业务取舍 29 | 30 | 业务规则 - 先验与回滚 31 | 32 | ``` 33 | 兑换优惠券可能需要消耗用户积分,前端必须实时做一次积分查询,积分充足则立即执行Redis扣减。 34 | 35 | 在任务处理时,对用户积分做真实扣减,若此时积分不足则可以将券码归还到redis中,并站内信通知用户。 36 | 37 | 因为最终业务凭证是以数据库为准的,而任务本身是一个准实时异步过程,所以业务前端应该给予用户合理的提示,例如"稍后到卡包中查看优惠券"。 38 | ``` 39 | 40 | 业务规则 - 前置与有损 41 | 42 | ``` 43 | 如果业务有类似限制IP领取几次等比较复杂的规则,优先考虑在redis lua之前做前置判定。 44 | 45 | 因为lua脚本中逻辑越多,redis的TPS越低,所以尽量保证lua只做库存核心扣减。 46 | 47 | 前置规则校验的缺点,就是可能因为前置规则更新了某些计数,而后续的redis lua扣减异常,导致用户再也无法抢该券码。 48 | 49 | 但是出于折衷,暂时没有更好的办法,可以根据业务需要做让步和取舍。 50 | ``` 51 | 52 | ## 性能 53 | 54 | ``` 55 | 云主机Redis:云存在超卖问题,性能也不是很稳定,库存扣减性能在2w-4w/s。 56 | 物理机Redis: 库存扣减<=9w/s,随lua逻辑的复杂度而稍微降低,但整体远优于云Redis。 57 | 58 | 压测均采用redis长连接,采用PHP-FPM无法达到预期,需要换一个语言来实现库存前端服务。 59 | 60 | 任务入库仅做流水的select/insert操作,可以基于批量插入优化实现高吞吐。 61 | 62 | 如果业务量需要单个商品/优惠券>=9w/s的秒杀量,可以考虑限流,否则绰绰有余。 63 | ``` 64 | 65 | ## 限流 66 | 67 | 因为单个优惠券的库存是单台redis执行lua来承载的,而redis是单线程程序,所以lua的TPS有限。 68 | 69 | ``` 70 | 在单个优惠券后端处理能力已知有限的情况下,有2个解决方案: 71 | 72 | 1)限流:也就是通过前端挡住大多数流量,只透传可控的流量到redis,给予用户友好的文案即可。 73 | 2)扩展:把优惠券的库存拆成N份,存储到多个redis实例,用户按uid打散到不同的redis实例进行扣减。 74 | 75 | 但是从公司量级来说,除非做到小米和京东的秒杀量级,否则上述方案大概一辈子也用不到。 76 | ``` 77 | 78 | ## 存储量 79 | 80 | ``` 81 | demo里直接把优惠券coupon放在redis里,为了节约存储空间可以考虑只放coupon的数据库id。 82 | 83 | 对于商品类秒杀,库存可能只是一个剩余数量,异步任务需要走商品库存服务完成真实扣减。 84 | ``` 85 | 86 | ## lua注意事项 87 | 88 | ``` 89 | 1,不能使用带有随机性质的函数(redis会直接报错),比如time(),spop()等,否则主从同步时在从库上重放脚本,会导致主从不一致。 90 | 2,在cluster/codis下,可以使用redis的hash tags机制,实现多个key路由到同一个slot的能力,确保lua可以访问到相关数据。 91 | 3,不要使用eval,每次上传脚本到redis性能很差;应该使用evalsha,提前计算好lua脚本的sha1值,当evalsha报错时再触发一次script load上传脚本,也就是懒惰上传。 92 | ``` 93 | 94 | ## 异常处理 95 | 96 | ``` 97 | 因为最终是数据库作为业务凭证,所以当redis主从切换等数据丢失场景出现时,至多出现少发的情况,绝对不会出现超发,因为在数据库层面可以基于coupon做去重。 98 | ``` -------------------------------------------------------------------------------- /app/controller/Quan.php: -------------------------------------------------------------------------------- 1 | $errno, 'msg' => $msg, 'data' => $data]); 18 | return; 19 | } 20 | 21 | /** 22 | * 秒杀优惠券 23 | */ 24 | public function fetch() 25 | { 26 | $uid = isset($_GET['uid']) ? $_GET['uid'] : 0; // 仅用于演示 27 | $quanId = isset($_GET['quanId']) ? $_GET['quanId'] : ''; 28 | if (empty($uid) || empty($quanId)) { 29 | return $this->response(-1, '参数不合法'); 30 | } 31 | 32 | // 某个用户抢某个优惠券 33 | $result = QuanService::fetch($uid, $quanId); 34 | if (empty($result)) { 35 | return $this->response(1, '服务端异常'); 36 | } 37 | return $this->response($result['errno'], $result['msg'], $result['data']); 38 | } 39 | 40 | /** 41 | * 模拟上传一个批次的优惠券 42 | */ 43 | public function upload() 44 | { 45 | $quanId = isset($_GET['quanId']) ? $_GET['quanId'] : ''; 46 | $count = isset($_GET['count']) ? $_GET['count'] : ''; 47 | 48 | if (empty($quanId) || empty($count)) { 49 | return $this->response(-1, '参数不合法'); 50 | } 51 | 52 | // 模拟一个批次ID,实际上应该走数据库去生成 53 | $batchId = time(); 54 | 55 | $result = QuanService::upload($quanId, $batchId, $count); 56 | if (empty($result)) { 57 | return $this->response(1, '服务端异常'); 58 | } 59 | return $this->response(0, '生成成功', $result); 60 | } 61 | 62 | /** 63 | * 命令行循环模拟uid压测 64 | */ 65 | public function test($quanId, $times) 66 | { 67 | $s = microtime(true); 68 | for ($i = 0; $i < $times; ++$i) { 69 | $uid = rand(0, 100000000); 70 | // 某个用户抢某个优惠券 71 | $result = QuanService::fetch($uid, $quanId); 72 | $u = intval( (microtime(true) - $s) * 1000 * 1000) . PHP_EOL; 73 | if ($i % 10000 == 0) { 74 | echo $i / (microtime(true) - $s) . PHP_EOL; 75 | } 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /common/config/myf.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'default' => [ 7 | 'dbname' => 'test_db', 8 | 'username' => 'test_user', 9 | 'password' => 'test_pass', 10 | 'charset' => 'utf8', 11 | 'master' => [ 12 | [ 13 | 'host' => 'localhost', 14 | 'port' => 3306 15 | ], 16 | ], 17 | 'slave' => [ 18 | [ 19 | 'host' => 'localhost', 20 | 'port' => 3306 21 | ], 22 | ] 23 | ] 24 | ], 25 | 26 | // redis配置 27 | 'redis' => [ 28 | 'default' => [ 29 | 'dbIndex' => 0, 30 | 'password' => false, 31 | 'isCluster' => false, 32 | 'timeout' => 2, 33 | 'readTimeout' => 2, 34 | 'master' => [ 35 | [ 36 | 'host' => 'localhost', 37 | 'port' => 6379, 38 | ] 39 | ], 40 | 'slave' => [ 41 | [ 42 | 'host' => 'localhost', 43 | 'port' => 6379, 44 | ] 45 | ] 46 | ], 47 | 'myCluster' => [ 48 | 'dbIndex' => 0, 49 | 'password' => false, 50 | 'isCluster' => true, 51 | 'timeout' => 2, 52 | 'readTimeout' => 2, // 高版本phpredis支持此选项 53 | 'master' => [ 54 | [ 55 | 'host' => 'localhost', 56 | 'port' => 6379, 57 | ], 58 | [ 59 | 'host' => 'localhost', 60 | 'port' => 6379, 61 | ] 62 | ], 63 | ] 64 | ], 65 | 66 | // elasticsearch配置 67 | 'elasticsearch' => [ 68 | 'default' => [ 69 | 'hosts' => ['localhost:9200', ], 70 | 'retries' => 2, 71 | 'connectionParams' => [ 72 | 'client' => [ 73 | 'timeout' => 2, 74 | 'connect_timeout' => 2, 75 | ] 76 | ] 77 | ] 78 | ], 79 | 80 | // http客户端配置 81 | 'http' => [ 82 | 'connectTimeout' => 1, // 连接超时1秒 83 | 'timeout' => 1, // 请求超时1秒 84 | ] 85 | ]; --------------------------------------------------------------------------------