Coder Social home page Coder Social logo

seckill's Introduction

Seckill

license Build Status

秒杀场景,即多个用户抢购同一件或者一定数量的商品。其特点是瞬时超高并发,并且数据库中商品数量较少。秒杀活动对于网站的冲击是非常巨大的,既考验网站的负载能力,又考验业务逻辑的合理与否。

秒杀系统需要面对的技术挑战有:

  1. 秒杀活动对于现有网站来说只是一个营销活动,具有时间短、并发访问量巨大的特点,不应该影响到网站原有业务。解决方法:将秒杀系统独立部署,使用独立子域名将其与网站完全分离。
  2. 秒杀活动开始前,用户通过不断刷新浏览器页面以保证不会错过秒杀,这些刷新请求对于动态页面造成严重的负载压力。解决方案:秒杀活动页面完全静态化,甚至可以将页面完全缓存在 CDN
  3. 秒杀活动的规则是只有到了开始时间,用户才能进行秒杀购买,在此之前只能浏览商品信息。但是秒杀的 API 也仅仅是普通的 API,如果有人提前知道了该 API,不用等到开始也可以购买了。解决方案:将秒杀 URL 动态化,加上一个由服务器随机生成的随机数作为参数,该参数只有在秒杀开始的时候才能得到。
  4. 如何控制秒杀页面的购买按钮只有在开始后才点亮。上面提到,为了减轻服务器的负载,将全部前端页面缓存到 CDN。因此,在活动开始后,用户的请求根本不会到真正的服务器。解决方案:在秒杀的静态页面加一个JavaScript文件引用,该文件的内容为活动开始与否的标志(例如:stop:true),活动开始后,生成新的 JavaScript 文件,内容为秒杀的 API 以及随机数。(随机数只生成一个,服务器端可以用 memcached 或 redis 来保存校验,服务器端采用分布式 redis 来保存、校验随机数)。这个 JavaScript 文件的加载可以采用随机版本号(xxx.js?v=123654978),这样就不会被浏览器、CDN 和反向代理服务器所缓存。因为文件非常小,所以即使经常刷新请求也不会对服务器造成太大负载压力。
  5. 并发库存处理。既要避免超售又要避免影响用户体验,有几种实现方法:Java 原生锁、数据库悲观锁、数据库乐观锁、分布式锁、队列。本项目主要着眼点就是该部分的实现。

以上,参考相关博客简单讲了讲分布式秒杀系统的几个要点,而本项目我们主要关注的是秒杀系统的实现,模拟大量用户同时并发请求,然后更新数据库库存。出于实现的复杂度考量,并没有加入重试的测试,但是已经将模拟并发的代码抽象为模块,所以在此基础上实现重试等操作也会很费力。

无锁实现

不包含任何同步手段,幻想抢购的人一个接着一个点击秒杀按钮。当然会超售....

JVM 锁实现

通过 ReentrantLock 提供的锁实现同步,但是由于锁在事务中获取并在事务中释放,所以存在“脏读”的情况,可能导致超售。

AOP 锁实现

通过 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 是一样的。具体实现需要配合重试,否则会有剩余库存的情况。

Redis 分布式锁

JVM 锁和 AOP 锁解决不了应用集群部署的情况,因为两个锁都是针对单个 JVM 进程来说的。这种时候,就需要分布式锁登场了,分布式锁即把加锁、检测锁状态、释放等操作交给可靠的第三方组件来完成。

Redis 分布式锁是通过 redis 的 setnx 实现的,setnx 只有在 key 不存在的时候才会设置 value,如果 key 存在,则不做任何动作。

基于 setnx 实现的分布式锁可能存在死锁的情况。设想一种情况:加锁的进程挂掉了,锁永远不会释放。所以我们提出了超时机制,在 setnx 获取到锁之后,为锁设定一个超时时间。但是加入超时机制后就有一个原子性问题浮现出来,setnxexpire 操作并不是原子的,假设 setnx 成功后,进程挂了,就又出现了死锁。

从 Redis 2.6 开始,客户端可以直接向 Redis Server 提交 Lua 脚本,脚本的提交相对于客户端是原子操作,于是 Redis 分布式锁的实现也简单了许多。Redisson 是 Redis 官方推荐的 Java Redis 分布式锁实现,其内部也是通过 Lua 脚本实现的 setnxexpire的原子操作,默认的过期时间是 30 秒,并且每隔 10(30 / 3) 秒刷新一下过期时间。

Zookeeper 分布式锁

分布式锁的另一种实现是 Zookeeper。Zookeeper 提供了一个多层级的节点命名空间,类似于文件系统,但是一点不同的是,zookeeper 中的所有节点都可以关联数据,而文件系统中目录节点不能关联数据。Zookeeper 的节点的几个性质提供了分布式锁实现的保障。

  1. Zookeeper 中提供了一个节点有序特性,比如创建节点 /lock/node- 并指定有序,那么 zookeeper 会自动根据当前节点数量添加整数序号,即第一个节点 /lock/node-001,第二个节点 /lock/node-002,依次类推。
  2. 临时节点。Zookeeper 可以创建一个临时节点,会话结束或者超时后自动删除。
  3. 事件监听。在读取数据的时候我们可以同时对节点设置事件监听,当节点的结构发生变化时,zookeeper 会通知客户端。zookeeper 中有以下四种事件:节点创建节点删除节点数据修改子节点变更

通过以上特性,我们得出了实现 zk 分布式锁的算法:

  1. 客户端连接 zookeeper,并在 /lock 下创建临时有序子节点,第一个子节点对应 /lock/node-000,第二个子节点对应 /lock/node-001
  2. 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否是当前子节点列表中序号最小的子节点,如果是,则认为获取到锁。否则监听 /lock 子节点变更消息,收到消息后重复此步骤。
  3. 执行业务代码
  4. 删除对应子节点

第二步中有两个问题,第一个是获取子节点列表与设置监听消息的原子性问题。考虑如下场景,客户端 a 对应子节点 /lock/node-000,客户端 b 对应子节点 /lock/node-001,客户端 b 读子节点列表,发现自己不是最小的,于是准备设置监听,于此同时,客户端 a 完成业务代码删除了对应的子节点,客户端 b 设置的监听器永远不会被触发。这个问题是不存在的,因为 zookeeper 提供了同时读子节点列表与设置监听器的 API,也就是说两个操作可以是原子的。

第二个问题是,监听 /lock 子节点变更消息的客户端可能很多,当最小子节点被删除后,所有等待的客户端都会被唤醒,产生羊群效应。所以有了以下的优化算法:

  1. 客户端连接 zookeeper,并在 /lock 下创建临时有序子节点,第一个子节点对应 /lock/node-000,第二个子节点对应 /lock/node-001
  2. 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否是当前子节点列表中序号最小的子节点,如果是,则认为获取到锁。否则监听自己之前一位子节点的删除消息,收到消息后重复此步骤。
  3. 执行业务代码
  4. 删除对应子节点

Java 原生队列

使用确定长度的 LinkedBlockingQueue,在 Controller 中入队,在后台的 TaskRunner 处理出队。出队的操作是单线程的,所以该实现效率不高。

Disruptor 队列

Disruptor 是一个高性能线程间队列,但是在我集成的时候,模拟 3000 个顾客同时抢购的情形中,Disruptor 的表现非常差劲,可能是我这边配置的问题,一开始抢购,我的整个笔记本都卡住了,只能鼠标能缓缓移动到停止上狂点几下。。

Redis 队列 pub/sub

通过 Redis 的 pub/sub 模式实现队列,在 Controller 中发送消息,Redis 的 Receiver 将其落地到数据库中,其中 Receiver 端还是需要锁同步。

Kafka 队列

Kafka 是另一种可靠的消息队列,Redis 是基于内存的,掉电或者进程被杀死消息就全部丢失了,而 Kafka 则提供了更多的功能以及可靠地消息传输。Kafka 同时也具有较高的吞吐量。

如何阅读源代码

建议从 Controller 开始读,从 API 开始,到 API 用到的组件。核心思路就是通过锁来确保数据的正确更新和较高性能。

Pull Request is Welcome!

如果有新的想法,欢迎 Issue 或者 Pull Request!

seckill's People

Contributors

xlui avatar

Watchers

 avatar  avatar

Forkers

shimaomao

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.