这是我进行的第一个与互联网实际业务相关的系统设计。
所谓“秒杀”,就是网络卖家以促销等目的,发布少量超低价格的商品,让所有买家在同一时间网上抢购的一种销售方式。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟。
秒杀业务区别于一般的后端业务,其在短时间内产生巨量的请求, 普通的 Web 服务器和数据库很可能撑不住这样的压力。 如果我们尝试使用集群来分摊流量,还需要解决一致性问题, 比如避免“超卖”(实际卖出商品数量大于预设的数量)等问题。
用户设计
但是首先,我们需要设计好“用户”,他是参与秒杀的主体。
我们先为用户创建这样一张表,里面都是些基本信息。
CREATE TABLE `seckill_user` (
`id` bigint(20) NOT NULL COMMENT '用户ID',
`nickname` varchar(255) NOT NULL,
`password` varchar(32) DEFAULT NULL COMMENT 'MD5(pass明文+salt)',
`salt` varchar(10) DEFAULT NULL,
`avatar` varchar(128) DEFAULT NULL COMMENT '头像',
`register_date` datetime DEFAULT NULL COMMENT '注册时间',
`last_login_date` datetime DEFAULT NULL COMMENT '上次登录时间',
`login_count` int(11) DEFAULT '0' COMMENT '登录次数',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为了防止数据库被黑客攻破后泄露了用户的密码
(很多用户喜欢在不同网站用同一个密码),我们的数据库不能存放原密码。
通过哈希函数(如 MD5),可以把任意密码变成另一串字符串。
因为哈希函数是不可逆的,黑客便无法反推原密码。
但是,黑客还可尝试跑彩虹表(即暴力枚举常见密码的 MD5 结果)来找到原密码。因此,我们改进了算法,
在计算 MD5 前为原密码拼接上一段随机字符串(即 salt
)。
而且不同用户的 salt
还不同,这便可以大幅增加黑客的破解开销。
然后便要考虑登录系统的设计。用户登录时我们从数据库中取得该用户的信息,然后比对密码即可。
我们自然不希望用户每次访问页面时都要重新登录,因此需要采取措施记住当前用户。
常用的措施便是 Session
(会话)。Session
是一种记录客户状态的机制,保存在服务器上,
通常采用键值对形式实现。Session
的键称为 Session Id
,值可以是用户账号信息、购物车列表等状态。
Session
工作流程一般是这样的:
- 客户端发送请求
- 服务端接收请求,分配一个
Session Id
并创建对应的状态信息 - 客户端再次访问时在请求中带上
Session Id
- 服务端解析
Session Id
,找到对应的Session
信息
这里有几点需要注意。
首先是 Session Id
的生成。Session Id
需要满足唯一性(防重复)与随机性(防止被人发现规律,
进行伪造)要求。所以最好不要用 UUID 之类的随机性不强的方法,
而是使用专门工具类的 Session Id
生成器,
比如 Tomcat
的基于 java.security.SecureRandom
的 SessionIdGenerator
。
参考Best practices for SessionId/Authentication Token generation
Session
可以简单存在 HashMap
中,也可用内存数据库存储。对于我们的秒杀系统,
并发量较大,单个 Tomcat
服务器可能撑不住,因此用 nginX
作反向代理、负载均衡。
通常负载均衡算法有 Round Robin
、随机分配、加权分配和源 IP
地址哈希法等。
前三种方法无法保证同一用户的多次 TCP
连接到达同一个 Tomcat
服务器,
因为 Session
存储在本地,其他服务器便无法提供 Session
服务。
按源 IP
地址哈希分配服务器可以保证,只要用户的 IP
不变(一般不会变),
他的 TCP
连接总会负载均衡到同一个 Tomcat
服务器。即使不用此法,也有办法达到目的,
那就是把 Session
单独放到一台 Redis
服务器上去。
所有的 Tomcat
共享一个 Session
存储空间————Redis
服务器,通过内网网络请求它。
如此一来,即使同一用户的多次 TCP
连接到达不同的 Tomcat
服务器,它们也能一致地维护用户状态。
另外,客户端处理 Session Id
的方法也有多种。常用方法是利用 Cookie
,
它是浏览器本地存储的 KV 值;传输时,服务器在响应报文中设置首部字段 Set-Cookie
的值,
带上 Session Id
,
浏览器以后便会在请求报文的首部字段 Cookie
中捎带上 Session Id
。
如果客户端禁用了 Cookie
,则可以用其他地方存储,例如 localStorage
、sessionStorage
;
传输时,可以改写 url 以带上 Session Id
(例如 xxx.com/?session=xxxxxxxxxxxxxxxx
),
或者自定义请求报文的首部字段(例如 xhr.setRequestHeader('X-SessionID-Header', 'B6SE3C66');
)等,服务器相应处理即可。
商品设计
商品表包含商品基本信息。
CREATE TABLE `goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`goods_name` varchar(16) DEFAULT NULL COMMENT '商品名称',
`goods_title` varchar(64) DEFAULT NULL COMMENT '商品标题',
`goods_img` varchar(64) DEFAULT NULL COMMENT '商品的图片',
`goods_detail` longtext COMMENT '商品的详情介绍',
`goods_price` decimal(10,2) DEFAULT '0.00' COMMENT '商品单价',
`goods_stock` int(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
极端情况下,秒杀系统的并发量可能会击垮数据库。所以,
我们最好把要秒杀的商品单独放进一张表中————即秒杀商品表,并单独部署于一个数据库中。
即使秒杀商品数据库被击垮了,也不会影响正常商品的业务逻辑。
虽然我们之后通过 Redis
缓存可以大幅缓解数据库压力,但是多一层防护没有害处,
毕竟缓存系统也有失效的可能。同时秒杀商品表还可供以后使用,毕竟秒杀系统不是一次性用品。
CREATE TABLE `seckill_goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀的商品表',
`goods_id` bigint(20) DEFAULT NULL COMMENT '商品Id',
`seckill_price` decimal(10,2) DEFAULT '0.00' COMMENT '秒杀价',
`stock_count` int(11) DEFAULT NULL COMMENT '库存数量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
秒杀商品表中的 goods_id
是连接商品表的外键。用户浏览商品时,对应的信息通过
秒杀商品表与商品表左连接得到。当然,从减少商品表压力的角度来说,
也可允许冗余、牺牲一致性保护,而把商品表中的属性复制到这里。
顺带讲一下连接
left join
(左连接或者叫左外连接):返回左表中的所有行(包括右表中连接字段相等的行)。right join
(右连接或者叫右外连接):返回右表中的所有行(包括左表中连接字段相等的行)。inner join
(等值连接或者叫内连接):只返回两个表中连接字段相等的行。full join
(全外连接):返回左右表中所有的行和左右表中连接字段相等的行。
MySQL 不支持全外连接,但可以通过合并左右连接的数据并自动去重得到:
select * from A left join B on A.name = B.name union select * from A right join B on A.name = B.name
秒杀功能设计
终于讲到了系统的核心功能。
首先,秒杀是有严格时限的活动,对于到达的秒杀请求,如果当前时间在秒杀开始前或结束后则直接拒绝。 这个时间限制可以通过配置文件等方式注入系统,系统在初始化后读取时限, 以后的判断就全在内存中进行。之所以能这样做,是因为秒杀业务通常不会临时变更时限, 系统便也不需动态获取。
其次,我们一定要防止“超卖”,即实际卖出的商品数量大于秒杀库存数量。 为此,我们在数据库上必须加锁保护。如果编写的 SQL 是先查询、再更新,因为两个操作不是原子的, 便会产生 ABA 问题。我们应当这样编写 SQL:
update seckill_goods set stock_count = stock_count - 1 where goods_id = given_id and stock_count > 0
这样一来,查询和更新在一条 update
语句中完成,数据库会自动为我们加锁以保证原子性。
然而,如果让所有的秒杀请求都到达数据库的话,压力还是太大了。
其实,只有最开始的一些请求可以成功,其他的请求得到锁时就会发现 stock_count
已为 0,直接失败。
所以我们实际上让 Redis
来预减库存、过滤请求。
我们可以一开始就把各种商品与其对应的库存信息加载到 Redis
中去,
之后的请求全由 Redis
来先行处理库存,如果库存为 0 就阻止其访问数据库。
这样一来,数据库的访问数就只等于秒杀成功数。
另外,我们还可以在预减库存失败后本地记住这个结果,以后对于同一个商品的秒杀直接失败,
连 Redis
都不再访问。
下面就是 SeckillController
中处理秒杀的方法实现。
@RequestMapping(value="/seckill", method= RequestMethod.POST)
@ResponseBody
public Result<Integer> seckill(SeckillUser user, @RequestParam("goodsId") long goodsId) {
Result<Integer> result = Result.build();
// 没登录
if (user == null) {
result.withError(SESSION_ERROR.getCode(), SESSION_ERROR.getMessage());
return result;
}
// localOverMap 是 Controller 内的 Map,用于内存标记,减少 Redis 访问
boolean over = localOverMap.get(goodsId);
if (over) {
result.withError(SECKILL_OVER.getCode(), SECKILL_OVER.getMessage());
return result;
}
// Redis 预减库存,decr 命令先减 1 再返回新值
Long stock = redisService.decr(GoodsKey.getSeckillGoodsStock, "" + goodsId);
if (stock < 0) {
localOverMap.put(goodsId, true);
result.withError(SECKILL_OVER.getCode(), SECKILL_OVER.getMessage());
return result;
}
// 数据库在某个事务中减库存、下订单
// ...
return result;
}
其他优化策略
浏览器与 nginX
都可以对一些静态资源进行缓存,以降低访问流量。
商品信息、用户信息、订单信息等都没有必要在每次请求时都到数据库中去请求,
我们也可以在 Redis
中缓存。商品、订单信息的键通过其在数据库中的 id
构建,值是对应的 pojo;
用户信息的键其实就是前面提到的 Session Id
,值也是 pojo,这其实就是 Session
了。
在 Redis
中缓存 Java
对象的行为称为对象缓存,可以通过“对象-JSON 转换”等方法实现。
可以针对某个 IP
进行访问频率限制,以削减并发量。
部分内容参考 CSDN博主「灰太狼_cxh」的文章
参考实现:
如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆
最后附上 GitHub:https://github.com/gonearewe