秒杀场景,即多个用户抢购同一件或者一定数量的商品。其特点是瞬时超高并发,并且数据库中商品数量较少。秒杀活动对于网站的冲击是非常巨大的,既考验网站的负载能力,又考验业务逻辑的合理与否。
秒杀系统需要面对的技术挑战有:
- 秒杀活动对于现有网站来说只是一个营销活动,具有时间短、并发访问量巨大的特点,不应该影响到网站原有业务。解决方法:将秒杀系统独立部署,使用独立子域名将其与网站完全分离。
- 秒杀活动开始前,用户通过不断刷新浏览器页面以保证不会错过秒杀,这些刷新请求对于动态页面造成严重的负载压力。解决方案:秒杀活动页面完全静态化,甚至可以将页面完全缓存在 CDN
- 秒杀活动的规则是只有到了开始时间,用户才能进行秒杀购买,在此之前只能浏览商品信息。但是秒杀的 API 也仅仅是普通的 API,如果有人提前知道了该 API,不用等到开始也可以购买了。解决方案:将秒杀 URL 动态化,加上一个由服务器随机生成的随机数作为参数,该参数只有在秒杀开始的时候才能得到。
- 如何控制秒杀页面的购买按钮只有在开始后才点亮。上面提到,为了减轻服务器的负载,将全部前端页面缓存到 CDN。因此,在活动开始后,用户的请求根本不会到真正的服务器。解决方案:在秒杀的静态页面加一个JavaScript文件引用,该文件的内容为活动开始与否的标志(例如:stop:true),活动开始后,生成新的 JavaScript 文件,内容为秒杀的 API 以及随机数。(随机数只生成一个,
服务器端可以用 memcached 或 redis 来保存校验,服务器端采用分布式 redis 来保存、校验随机数)。这个 JavaScript 文件的加载可以采用随机版本号(xxx.js?v=123654978),这样就不会被浏览器、CDN 和反向代理服务器所缓存。因为文件非常小,所以即使经常刷新请求也不会对服务器造成太大负载压力。 - 并发库存处理。既要避免超售又要避免影响用户体验,有几种实现方法:Java 原生锁、数据库悲观锁、数据库乐观锁、分布式锁、队列。本项目主要着眼点就是该部分的实现。
以上,参考相关博客简单讲了讲分布式秒杀系统的几个要点,而本项目我们主要关注的是秒杀系统的实现,模拟大量用户同时并发请求,然后更新数据库库存。出于实现的复杂度考量,并没有加入重试的测试,但是已经将模拟并发的代码抽象为模块,所以在此基础上实现重试等操作也会很费力。
不包含任何同步手段,幻想抢购的人一个接着一个点击秒杀按钮。当然会超售....
通过 ReentrantLock 提供的锁实现同步,但是由于锁在事务中获取并在事务中释放,所以存在“脏读”的情况,可能导致超售。
通过 Spring Aspect 提供锁,利用注解 @ServiceLock
并在注解的实现类上注解 @Order(1)
确保 @ServiceLock
在 @Transactional
之前被处理。
Spring 默认处理多个注解的顺序是 undefined,所以我们需要 @Order
来确保锁在事务前获取,在事务提交后释放。
通过 select ... for update
,该语句会锁定查询的行,直到当前事务提交后,其他事务或语句才能更新被锁定的行。
Spring JPA 中的实现是通过 @Lock
注解:
// pessimistic write lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i.count FROM Item i WHERE i.itemId=:id")
long findCountByItemIdForUpdate(@Param("id") long itemId);
代码语义应该很清晰了。
秒杀过程一般分为两步,检查库存,进行秒杀。通过在一条语句做这两件事,理论上应该也算悲观锁的一种:
UPDATE item AS i SET i.count=i.count-1 WHERE i.itemId=itemId AND i.count > 0
通过在表中加一个 version
字段,在检查库存的时候将 version
读出来,更新库存的时候检查 version
是否为读出来的值,思路上跟 CAS 是一样的。具体实现需要配合重试,否则会有剩余库存的情况。
JVM 锁和 AOP 锁解决不了应用集群部署的情况,因为两个锁都是针对单个 JVM 进程来说的。这种时候,就需要分布式锁登场了,分布式锁即把加锁、检测锁状态、释放等操作交给可靠的第三方组件来完成。
Redis 分布式锁是通过 redis 的 setnx
实现的,setnx
只有在 key 不存在的时候才会设置 value,如果 key 存在,则不做任何动作。
基于 setnx
实现的分布式锁可能存在死锁的情况。设想一种情况:加锁的进程挂掉了,锁永远不会释放。所以我们提出了超时机制,在 setnx
获取到锁之后,为锁设定一个超时时间。但是加入超时机制后就有一个原子性问题浮现出来,setnx
和 expire
操作并不是原子的,假设 setnx
成功后,进程挂了,就又出现了死锁。
从 Redis 2.6 开始,客户端可以直接向 Redis Server 提交 Lua 脚本,脚本的提交相对于客户端是原子操作,于是 Redis 分布式锁的实现也简单了许多。Redisson 是 Redis 官方推荐的 Java Redis 分布式锁实现,其内部也是通过 Lua 脚本实现的 setnx
与 expire
的原子操作,默认的过期时间是 30 秒,并且每隔 10(30 / 3) 秒刷新一下过期时间。
分布式锁的另一种实现是 Zookeeper。Zookeeper 提供了一个多层级的节点命名空间,类似于文件系统,但是一点不同的是,zookeeper 中的所有节点都可以关联数据,而文件系统中目录节点不能关联数据。Zookeeper 的节点的几个性质提供了分布式锁实现的保障。
- Zookeeper 中提供了一个节点有序特性,比如创建节点
/lock/node-
并指定有序,那么 zookeeper 会自动根据当前节点数量添加整数序号,即第一个节点/lock/node-001
,第二个节点/lock/node-002
,依次类推。 - 临时节点。Zookeeper 可以创建一个临时节点,会话结束或者超时后自动删除。
- 事件监听。在读取数据的时候我们可以同时对节点设置事件监听,当节点的结构发生变化时,zookeeper 会通知客户端。zookeeper 中有以下四种事件:
节点创建
、节点删除
、节点数据修改
、子节点变更
通过以上特性,我们得出了实现 zk 分布式锁的算法:
- 客户端连接 zookeeper,并在
/lock
下创建临时、有序子节点,第一个子节点对应/lock/node-000
,第二个子节点对应/lock/node-001
。 - 客户端获取
/lock
下的子节点列表,判断自己创建的子节点是否是当前子节点列表中序号最小的子节点,如果是,则认为获取到锁。否则监听/lock
子节点变更消息,收到消息后重复此步骤。 - 执行业务代码
- 删除对应子节点
第二步中有两个问题,第一个是获取子节点列表与设置监听消息的原子性问题。考虑如下场景,客户端 a 对应子节点 /lock/node-000
,客户端 b 对应子节点 /lock/node-001
,客户端 b 读子节点列表,发现自己不是最小的,于是准备设置监听,于此同时,客户端 a 完成业务代码删除了对应的子节点,客户端 b 设置的监听器永远不会被触发。这个问题是不存在的,因为 zookeeper 提供了同时读子节点列表与设置监听器的 API,也就是说两个操作可以是原子的。
第二个问题是,监听 /lock
子节点变更消息的客户端可能很多,当最小子节点被删除后,所有等待的客户端都会被唤醒,产生羊群效应。所以有了以下的优化算法:
- 客户端连接 zookeeper,并在
/lock
下创建临时、有序子节点,第一个子节点对应/lock/node-000
,第二个子节点对应/lock/node-001
。 - 客户端获取
/lock
下的子节点列表,判断自己创建的子节点是否是当前子节点列表中序号最小的子节点,如果是,则认为获取到锁。否则监听自己之前一位子节点的删除消息,收到消息后重复此步骤。 - 执行业务代码
- 删除对应子节点
使用确定长度的 LinkedBlockingQueue,在 Controller 中入队,在后台的 TaskRunner 处理出队。出队的操作是单线程的,所以该实现效率不高。
Disruptor 是一个高性能线程间队列,但是在我集成的时候,模拟 3000 个顾客同时抢购的情形中,Disruptor 的表现非常差劲,可能是我这边配置的问题,一开始抢购,我的整个笔记本都卡住了,只能鼠标能缓缓移动到停止上狂点几下。。
通过 Redis 的 pub/sub 模式实现队列,在 Controller 中发送消息,Redis 的 Receiver 将其落地到数据库中,其中 Receiver 端还是需要锁同步。
Kafka 是另一种可靠的消息队列,Redis 是基于内存的,掉电或者进程被杀死消息就全部丢失了,而 Kafka 则提供了更多的功能以及可靠地消息传输。Kafka 同时也具有较高的吞吐量。
建议从 Controller 开始读,从 API 开始,到 API 用到的组件。核心思路就是通过锁来确保数据的正确更新和较高性能。
如果有新的想法,欢迎 Issue 或者 Pull Request!