redis在学生抢房应用中的实践总结

文章目录

#背景简介
最近一个月,我们做了一个学生抢房的项目。考虑到抢房有一定的并发量(其实并没有那么大,被批次给隔离开来了),我们在抢房的项目中采用了全量redis的做法,本文主要是关于这个项目中涉及到redis使用的一个总结。

为了行文的方便,下面简单介绍一下抢房项目。其实这个抢房应用的使用对象主要是给学校的硕士,博士使用(他们在选择宿舍的方面比本科生拥有更多的自由,本科生的宿舍目前还是走的分配流程)。抢房是将手动选择宿舍的过程自动化,来让系统代替你选房。而让系统帮你选的方法是告诉系统你的意愿,因此抢房的过程依赖意愿。另外,你可以邀请你愿意合住的人来一起抢房,这又延伸出一个功能——组团,当然你也可以直接手动选房,如果你不满意你选择的宿舍,可以反悔重选。本人在选房应用中主要实现了如下几个业务:一键抢房手动选房反悔组团/邀请宿舍意愿,这些也基本涵盖了选房的主要功能点。

#分布式锁

先简单说明一下,我们在这个版本的抢房设计中,并没有引入请求排队的机制,因此抢房基于的是抢占式的模式。在抢房的初期,是应用流量的高峰。在这样高并发的场景下,让redis里的数据尽量保持一致性,必须采用分布式锁的方式来保证对临界资源(主要指宿舍)的互斥读写。redis常用的分布式锁的实现方式:

##setbit / getbit
用索引号为0的第一个比特位来表示锁定状态,其中:0表示未获得锁,1表示已获得锁。优势:简单;劣势:竞态条件(race condition),死锁。获得锁的过程至少需要两步:先getbit判断,后setbit上锁。由于不是原子操作,因此可能存在竞态条件;如果一个客户端使用setbit获取到锁,然后没来得及释放crash掉了,那么其他在等待的客户端将永远无法获得该锁,进而形成了死锁。所以,所以这种形式不太适合实现分布式锁。

##setnx / del / getset
redis官网有一篇文章专门谈论了实现分布式锁的话题。基本的原则是:采用setnx尝试获取锁并判断是否获得了锁,setnx设置的值是它想占用锁的时间(预估):

1
SETNX lock.foo <current Unix time + lock timeout + 1>

setnx命令简介:即:set if not exist,如果key不存在则设置值,返回1;否则,不设置,直接返回0。

通过del删除key来释放锁。某个想获得锁的客户端,先采用setnx尝试获取锁,如果获取失败了,那么会通过get命令来获得锁的过期时间以判断该锁的占用是否过期。如果跟当前时间对比,发现过期,那么先执行del,然后执行setnx获取锁。如果整个流程就这样,可能会产生死锁,请参考下面的执行序列:

1
2
3
4
1:user1 获取到锁,然后crash掉未释放锁
2:user2 get锁的过期时间,发现过期,del,并setnx获得锁
3:user3 get锁的过期时间,发现过期,del,并setnx获得锁
4:此时产生错误,因为步骤2的user2跟步骤3的user3都获得了锁

所以,在高并发的场景下,如果检测到锁过期,不能简单地进行del并尝试通过setnx获得锁。我们可以通过getset命令来避免这个问题。来看看,如果存在一个用户user4,它通过调用getset命令如何避免这种情况的发生:

1
2
user4:get锁的过期时间,发现过期;
user4: 通过getset命令尝试设置新的过期时间

getset设置的过期时间跟上面的setnx设置的相同:

1
GETSET lock.foo <current Unix timestamp + lock timeout + 1>

getset命令用于给一个key设置最新的结果并返回设置之前的结果。

如果该命令返回的结果跟上一步通过get获得的过期时间一致,则说明这两步之间,没有新的客户端抢占了锁,则该客户端即获得锁。如果该命令返回的结果跟上一步通过get获得的过期时间不一致,则该锁可能已被其他客户端抢先获得,则本次获取锁失败。

这种实现方式得益于getset命令的原子性,从而有效得避免了竞态条件。并且,通过将比对锁的过期时间作为获取锁逻辑的一部分,从而避免了死锁。

##setnx / del / expire
这是我们目前的实现方式:setnx的目的同上,用来实现尝试获取锁以及判断是否获取到锁的原子性,del删除key来释放锁,与上面不同的是,使用redis自带的expire命令来防止死锁(可能出现某个客户端获得了锁,但是crash了,永不释放导致死锁)。这算是一种比较简单但粗暴的实现方式:因为,不管实际的情况如何,当你设置expire之后,它一定会在那个时间点删除key。如何当时某个客户端已获得了锁,正在执行临界区内的代码,但执行时间超过了expire的时间,将会导致另一个正在竞争该锁的客户端也获得了该锁,这个问题下面还会谈到。

我们来看一下宿舍锁的简单实现很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void lockDorm(String dormId, String batchId) throws DaoException {
String key = String.format(Constant.LOCK_FOR_DORM_WITH_BATCH_KEY_PATTERN, batchId, dormId);
boolean gotLock = CacheUtil.setnx(key, "");
if (gotLock) {
CacheUtil.expireWithMillis(key, Constant.DEFAULT_LOCK_EXPIRE_MILLIS_SECOND);
} else {
long counter = Constant.DEFAULT_LOCK_EXPIRE_MILLIS_SECOND / Constant.DEFAULT_SLEEP_MILLISECOND;
try {
while (!CacheUtil.setnx(key, "") && --counter > 0) {
TimeUnit.MILLISECONDS.sleep(Constant.DEFAULT_SLEEP_MILLISECOND);
}
} catch (InterruptedException e) {
}
}
}

通过一个while(true),在当前线程上进行阻塞等待,并通过一个计数器进行自减操作,防止永久等待。

##double check
和在java中并发访问的代码段的if判断容易出现竞态条件一样。在使用分布式锁的时候,我们最好也同样在锁的前后做一下double check:

1
2
3
4
5
6
7
8
9
10
11
//double check
if (!dormChooseDao.hasAllocatedDorm(studentId)) {
try {
dormChooseDao.lockDorm(dormId, batchId);
if (!dormChooseDao.hasAllocatedDorm(studentId)) {
//business logic
}
} finally {
dormChooseDao.unlockDorm(dormId, batchId);
}
}

这是因为lock的时间可长可短,在任何时候redis的内存数据都可能发生改变。所以无论从业务逻辑还是技术实现上来看,double check都很有必要!

##共同的缺陷
以上第二种、第三种实现分布式锁的方式都有共同的缺陷:失效时间都是预估时间。也就是说,理论上你无法判定你真正需要占用某个锁多少时间,你只能根据场景来评估一个所谓的“绝对安全时间”。它指的是你认为在当前的业务场景下,不管任务执行得多慢,这个锁都不会过期,它的目的是用来防止死锁的。但一旦你评估的这个过期时间过短,可能存在在这段时间内无法执行完某个任务时,那么将导致多个客户端都能获得锁的问题。我们就遇到过这种情况,当时我们将锁的过期时间设置为10秒,结果在压测时由于线程太多,线程上线文切换太频繁,有任务没有被操作系统及时唤起,导致任务的执行时间超过10秒,而此时expire会使锁对应的key失效,这时该任务还没有释放锁,锁又被其他任务以setnx获得。而当我们将过期时间设置为2分钟之后,这种expire导致的Bug就没再发生过。

##锁使用总结
在抢房业务中,我们主要使用了三个分布式锁:宿舍锁团成员锁批次锁。它们的作用:

1
2
3
宿舍锁:抢房时,对宿舍床位数的修改必须保证其互斥性
团成员锁:主要用于避免团里多个成员,分别点一键抢房或手动选房导致锁定的宿舍不一致的情况
批次锁:当在选房的过程中修改批次时,需要锁定整个批次

其中,团成员锁跟宿舍锁是嵌套关系,也就是说,一个团里的所有成员一起抢房,在后端其实是顺序抢的。

#内存一致性
在用redis做这个应用的后期,我们遇到越来越多的因为内存无法保证一致性带来的问题。这确实是个问题,比如:
在选房过程中,删除了一个意愿、在选房的过程中进行组团、退团的操作。这里我们忽略从业务角度来看允许这些操作的的合理性。这些操作都是针对redis的分布式内存进行的。可以将redis的分布式内存跟JVM的内存模型进行一个对比:其实两者都采用了经典的共享内存的通信方式,JVM内存模型的不同之处在于:主线程创建附属线程之后(这里我们为了简单起见,假设附属线程是由主线程创建),主线程跟附属线程之间共享的数据其实并不是实时、一致的,附属线程见到的只是主线程内相同数据的副本,但是JVM的内存模型提供了“happen before”原则来保证内存的可见性。这使得在这种原则下,一些设置可以对其他线程立即可见。而redis提供的这种分布式的内存,并没有这样的模型来保证内存的一致性,而且网络传输的时延,会让这一问题加剧,为了保障内存的一致性,只能采用锁机制来保证对核心共享数据修改操作的排他性跟顺序性。但粒度大,数量少的锁导致性能太差粒度小数量多的锁导致复杂度太高。目前我们的选择是,以性能为主,暂时忽略这些小概率事件带来的问题。

#数据一致性
组团的协商来自于线下,但团里的每个成员对意愿都有平等的操作权(添加、删除、重新排序等),这些动作却是在系统中完成的,团里的每个人的意愿都是相同的。这就衍生出数据一致性问题——如何保证同一个团队里的每个成员,在最新的意愿集合上进行操作。

这个问题无论是在RDBMS中还是Redis中都存在。当然数据库对于一致性的处理方式已经相当成熟(比如:行锁)。但在内存中,要保证这样的数据一致性并不容易。

##设置版本时间戳
我们可以通过对比时间戳来解决这个问题。当团队的每个成员访问意愿操作页面时,后端会在页面上设置该团队对意愿操作的最新时间戳。每次只要涉及到对意愿的任何操作请求,都需要带着时间戳,后台在执行该操作命令之前,会去数据库比对时间戳(也即redis里的时间戳是否等于请求中传来的时间戳)如果不相等,则说明在这之前团队的意愿已经被其他成员修改过;如果相等,则执行该操作,并修改redis中的时间戳为当前最新时间戳,并将最新的时间戳返回给客户端作为最新的存根。

##时间戳的一致性
目前,我们暂时还采用的web服务器的本机时间,这种情况当web server是一个集群环境时,将要求非常严格的时钟同步,并且很难不出现误差。所以下一步我们的打算直接采用redis自带的时间戳机制。redis自从2.6.0开始,提供一个time命令,它能返回redis Server的unix timestamp。这样对单台redis server而言,所有web 服务器的时间戳也都是一致的。

#DDOS黑名单设计

抢房场景跟抢购类似,它有个开始倒计时页面。不管给出怎样的设计,总有人在这个倒计时页面上狂刷,妄图更早得进入开抢环节。所以,为了保证系统的可靠性,我们对一些可以预先感知的高频页面URL以及API接口进行了DDOS(拒绝服务攻击)的防护。

我们的业务系统外层有Nginx做反向代理,它已经从技术层面上做了一层DDOS拦截,下面谈及的DDOS防护是业务层面上的,针对那些过了外层限制之后能够进到业务系统内部的、我们认为不合理的请求频次。举个例子:在系统说明页面,我们告诫用户不要过度刷新倒计时页面,但如果我们还是在后台检测到某用户的请求频次大于10次/秒;或者某个API,只有点击按钮才能触发一次请求,但我们在后台检测到某用户对该API的请求频次大于10次/秒。对于这样的请求频次,我们认为是恶意的,有可能客户端采用了非寻常的手段向服务器发起请求。

对于恶意刷的后果是惩罚。惩罚的目的是劝诫或警告用户不要再进行此操作,惩罚的手段是:

  • 如果还没开始抢房,则延后抢房的开始时间;
  • 如果已开始抢房,则将用户重定向到一个繁忙页面,15秒后才能再次进入

毫无疑问,这种DDOS统计肯定是以学生为粒度的,所以key会带着学生的编号的:

1
key:studentHitStatistics.${studentId}.${uri}

而所谓的频次其实可以解读为:每多少秒内,命中了多少次。前面的每多少秒指代一个统计的region,命中多少次,这借助了redis的原子计数器,这个值将会与一个设定的threshold进行比对。当一个region结束之后,这段时间内的命中次数应该被清0,不应采取累积判断的方式。而清0的动作,正好借助于redis的expire命令,expire的具体时间则是region的时长。拦截的动作,借助于Filter即可。

##expire的坑
在最初实现完这个功能后,我们并没有发现这样的实现有什么问题。直到有一次,我们进行了压力测试,测试完之后,我们每次正常访问黑名单内的URL,都会被重定向到busy页面(这是我们应用惩罚的页面)。理论上,一段时间之后,expire会导致之前的统计全部清0(key被删除),因此不可能再达到惩罚的阈值。我们查看了一下redis统计相关的key居然还是存活的,并且ttl命令显示这些key永不失效。这段代码的实现逻辑大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public synchronized long incrStatistics(String batchId,
String studentId,
String uri,
long milliSec) throws DaoException {
String key = String.format(Constant.STUDENT_HIT_STATISTICS_PATTERN,
batchId, studentId, uri);
// if first init
if (CacheUtil.setnx(key, "1")) {
boolean expiredSuccessfully = CacheUtil.expireWithMillis(key, milliSec);
if (!expiredSuccessfully) {
logger.info("key : " + key + " set expired failed! ");
}
return 1;
} else {
return CacheUtil.increment(key, 1);
}
}

一开始我们以为上面的这段代码可以一直使得设置的每个key带有过期时间,并将最终因为过期而被redis自动清除。但现实却不是这样。经过分析发现,可能存在着这样的一个执行序列:

1
2
3
4
5
step 1: user1 成功初始化该统计为1,并顺便设置key的生存时间,假设10
step 2: 后面每次在该统计时间段内触发刷新,都会走到else分支,将统计进行++操作
...
step n-1: 直到统计快结束的前夕(key快过期了),user1进来后,判断if还是返回false
step n: 程序开始走else分支的++操作,但不巧,在incr命令到达redis之前,或者已到达但命令还没执行时,key失效了。此时incr命令,会初始化该key0,然后再执行incr命令。

当出现上面的执行序列之后,被incr命令初始化的key是不带过期时间的!因此这时你用ttl命令查看该key的剩余过期时间,将会看到-1(即永不过期)!当出现这种情况时,后面的统计将会一直走else分支,并且一直累加,过了阈值之后,每次都满足惩罚条件,也就永远停留在了惩罚的界面上。

这种情况,没有办法避免,因为代码虽然是可控的,但内存却是分布式的,expire的时机也不受我们控制,这里没有JVM内存模型的保障(volatile关键字以及happen before原则)。

所以,针对这种情况,只能选择一种补救措施。做法跟unlock类似:每应用一次惩罚,就将该key直接删除。这样虽然统计的区间(expire设置的过期时间)是动态变化的,但不会出现上面的这种永不过期、一直累加的情况。而且惩罚一次,就清空一次记录,逻辑上也没太大问题。

关于这个设计更好的解决方案,我另开了一篇文章进行了说明:《redis实现访问频次限制的几种方式》

#处理关系的痛苦
redis只有基于key的一级索引,这种情况使得原先可以通过数据库实现的一些关系查询变得更加繁琐。
举个例子,在组团/邀请业务中,想要组团的同学之间需要相互发送邀请。每个人都可以邀请别人和被别人邀请。如果从数据库的角度来看,这就是一个典型的多对多关系表。我们来看看,如果将这个功能改为全量redis实现需要哪些步骤。首先我们需要给每个学生设计两个list:

1
2
3
4
//当前学生发起的邀请
key : invitationFrom.${studentId}
//当前学生收到的邀请
key : invitationTo.${studentId}

因为有按时序排序的需求,所以这里邀请的集合我们选择list而不是set。

为了节约空间,我们将邀请对象序列化为json格式后的字符串进行存储,邀请对象设计如下:

1
2
3
4
5
6
7
8
public class Invitation {
private String studentId;
private String studentName;
private long timestamp;
private String status; //0: 未处理; 1:同意; 2: 拒绝
}

发送的邀请与接收到的邀请共用上面同一个对象。

接受邀请的逻辑是:只能接受来自一个团成员或单个学生的邀请,一旦接受邀请,将会自动拒绝发送给自己的其他邀请。因此要将invitationTo.${当前学生编号}的其他邀请全部都标记为拒绝,与此同时,还需要将这些被拒绝的邀请人的invitationFrom.${被拒绝的学生编号}集合中的发送给自己的邀请对象的状态置为拒绝。

这种双向关系查找在redis里极其麻烦,需要手动在很多集合中遍历,当一个用户收到的邀请很多时,遍历的开销会更大。这里其实跟邀请对象的存储没有关系(即使你将邀请对象存储为hash结构,情况也不会好到哪儿去)。主要的问题是缺乏对如下两点的有效评估:redis没有二级索引、数据关系处理支持的缺失。

这不是redis的问题,而是应该选择合适的存储引擎存储合适的数据的问题。因此,我们应该更早得认识到redis并不擅长处理强关联关系,它更适合存储一些简单的数据,比如静态对象。这里所谓的静态对象,具体指的是几乎不会变动的核心基础数据。比如,这个业务场景里的学生基本信息、宿舍基础信息等。它们在这里纯粹是被当作缓存来加快数据的访问速度。

#关于事务
redis并没有对事务提供支持。这也使得在实现一些逻辑关联性很强的并且应该被视为原子性的业务时,显得力不从心。我们的替代方案是分布式锁+pipeline。分布式锁用于实现互斥地修改,pipeline主要用于多命令批量提交。分布式锁在这里非常重要,它有助于提高高并发下数据的一致性,但也不能做到完全保证,并且最大的硬伤是没有rollback(回滚)机制。所以,我们还有一道最后的保障——归档。归档用于保证数据的最终一致性,它会校验数据的一致性问题,并在适当的适合进行纠正。这跟抢购业务跟第三方对账的处理方法很相似。

#pipeline批处理
pipeline 以管道的形式批量地提交命令集。这种命令提交方式非常有效地减少client跟server之间的通信次数。另外pipeline的方式非常适合于那种关联性、紧凑性很强的“事务型”操作,这有助于减少在高并发下因为多次数据往返出现不一致的几率。

从面向对象的设计角度来看,这种方式是违背松耦合性。耦合松散的代码的实践不推荐在一个方法/函数内部聚集太多功能集,因为这会降低可读性、复用性、可维护性。

在程序设计时,是否使用pipeline需要具体问题具体分析:

  • pipeline : 基础数据初始化、大量关联数据的读取、关联性极强的写(set)操作。
  • 功能拆分 : 适合大部分场景下的普通业务场景

#交互设计的重要性

抢房的原型设计将房源预览(意愿)、组团、抢房三个功能点集中在一个页面上(各个功能之间通过选项卡切换)。连同倒计时的入口页面,总共也就两个页面。因为时间比较赶再加上完全前后端分离的原因,这个问题并没有引起我们的注意。直到前后端开始集成的时候,才发现了这种设计的问题:因为几个功能点被集中到一个页面上,导致各种状态的判断很复杂。而前后端分离的方式,导致除了第一次加载页面时,后端渲染模板时能做一些逻辑判断,其他时间,很多状态的判断都需要前端完成(比如,选房选项卡的状态、多个选项卡内的操作需要连带局部刷新其他选项卡内的最新数据等等)。

这还不是这种大页面带来的唯一坏处,最大的问题是:在选房快开始的那段时间内,用户为了快速进入选房选项卡,对页面的整页刷新会连带“惩罚”其他两个选项卡在初始化时发出的“多余”请求。从需求分析的角度来看,选房功能跟房源预览以及组团页面,它们的请求频次不是一个级别,但选房功能在呈现的时候却跟另外两个非高频请求的功能捆绑在一起,这是不合理的。

当业务上可以预计到某个功能将会发生高频全页面刷新时,其他功能应尽量跟它隔离开来(拆成多个页面)。当然,幸好让各个选项卡的内容实现按需、异步加载。如果所有内容都要靠后端渲染,那么性能将会急剧下降。

#请求队列

现有的多线程 共享内存 抢占式式的选房设计,让我们有些如履薄冰。

其原因之一是之前并没有太多的分布式高并发经验。另外一个原因:从设计的角度来看,将请求排队确实是一种伸缩性更强的实践。

##队列的好处
伸缩性:前端的web server并发在线的峰值将会大大降低。

复杂度:后端的设计复杂度会得到降低,需要一个独立的选房服务;而前端的复杂度会有所升高,前端将会是一种:短请求阻页面多轮询的方式。

无锁化:通过对现有版本的抢房并发测试,我们也发现并发数越高、意愿越趋同,抢房耗时将越长,这里面大部分的时间都消耗在了锁竞争上。

单线程:上面的无锁化是单线程带来的福利。单线程会省去锁带来的时间浪费和调试代价,但多线程对应用的并发能力的提升确实是立竿见影的。

体验好:队列可以使得请求串行化,可控、可评估。也因此你甚至可以向用户直播抢房的进度:前面还有多少人排队你是第几位抢到宿舍的人,击败了百分之多少的同学。而多线程,抢占式意味着随机,你无法把控整个进度。

##数据按维度隔离
上面说到了队列的一些特点,但我们并不否认多线程、并行任务的优势。事实上,只要我们能够很好地将数据进行隔离,那么我们可以融合两种设计。

在选房业务中,批次是界定很多资源的依据。每个学生只能从属于一个批次,每个批次中的每个宿舍的可用床位数都是预先分好了的。那么从这个角度来看,每个批次都可以成为功能分割单位。

所以,我们可以以批次来区分请求队列。每个队列以单线程的形式抢房,因为每个队列所需要的资源是独立不耦合的,因此也就无需互斥锁。而如果多批次同时抢房,将会利用多线程带来的优势。

关于这种模式一个很形象的类比图:

queue

##概要设计
为了避免太高的复杂度,我们可以使用redis的List结构实现队列。在key上以批次编号来区分:

1
redis key : requestQueueOfBatch.${batchId}

队列里的每一个请求,都是一个序列化后的json对象的字符串。请求对象大致包含如下一些属性:

1
2
3
4
5
6
7
8
class Request {
String requestId; //分布式请求编号
String studentId; //学号
String batchId; //批次编号
long requestTimestamp; //请求时间戳
short type; //类型: 0:手动选房 /1:一键选房
String dormId; //宿舍编号(如果为手动选房)
}

选房服务是一个独立的服务。尽量不要放在web server上,因为它是一个CPU密集型的服务。有多少个批次就初始化多少个线程对应多少个队列,每个线程不断地处理属于自己队列的请求。走完选房逻辑之后,将结果回写到学生信息、分配结果相关的数据结构。

最终,用户看到的结果来自于浏览器的ajax轮询。轮询的频次会对后端的web server有直接的影响。不过,对于选房这种不得不使用也不得不关心的硬需求,轮询频次低一点,无伤大雅。

#总结
这是一个全量使用redis的项目,整个过程基本上还算顺利。redis作为抢购业务的选择,在性能上自不必多说,一般几千的并发几乎毫无压力。但由于分布式内存操作的原因,一致性很难做到绝对保证,所以最后必须有一个落库校准补偿的过程。另外一个建议是,慎用、少用分布式锁。