最近开始做一个通用抽奖的项目,项目中有一个活动用户每天的抽奖限制和总中奖限制,即每天最多抽多少回和活动期间总共中多少个。这一判断的特定就是请求量巨大,肯定是不能直接依靠关系数据库来做判断的,所以就要用到了缓存,将每一位用户的这两个限制都写到缓存中,需要判断限制的时候,直接读缓存就好。
首先先否定内存缓存,因为尽管缓存的数据是非常小的,但是参与抽奖的用户数量会很大,更何况一个应用会服务多个活动,那么用户数量更加不可估计,内存缓存很容易挤爆并丢失数据。
当然还有一个原因就是环境,项目的环境是容器,多容器之前无法共享内存缓存中的数据,而抽奖限制要求又必须是唯一的,所以内存缓存只能抛弃。
于是就来到了redis。
最开始的缓存逻辑,是将用户的每日抽奖限制和总中奖限制缓存在一起,在需要更新时,是将限制人为的+1,然后更新缓存。这几乎是新接触后端开发的新手都会犯的错误,这样的项目上线是一定会超发奖品的,因为后端应用是多线程并行运转。就比如限制中奖5个,一个用户已经中了3次,这时他找了个脚本,同时发了100次请求,那么这100次请求全部会读这一个限制,然后全部通过限制判断,100个都可能中奖了。会发生这种情况的原因就是没有应用到锁机制。
熟悉MySQL的同学应该都清楚MySQL的锁和事务机制,这里就不详细说了。如果redis像MySQL一样支持锁机制和事务,那么我们的问题不就迎刃而解了?
这样想的我真的是naive了,redis即支持锁机制又有事务,但就恰恰并没有解决我的问题。(当然这里的锁机制并非其他应用使用redis实现分布式锁的概念)。
redis的锁本质上是一种CAS(check-and-set)操作,即乐观锁,使用watch命令配合muti-exec的事务操作来实现,即在watch key1,之后在执行事务之前会去检验key1中的值是否有被更改过,如果有被更改过,则拒绝执行事务。乍一看这是能解决我的问题的,你在每个用户进来抽奖的时候watch这个用户的对应限制key不就可以了?
但很遗憾并非如此
首先,redis的muti-exec事务操作并非像MySQL中的事务一样为原子操作,一系列操作中的某一个操作出现问题了,redis会记录此次问题然后继续执行余下的操作,而且不具备rollback的功能。
其次,redis的事务对多线程的操作是不安全的(尽管redis服务端是单线程的),exec操作会清空watch key的列表,即假如先后进来抽奖者a和b,分别watch了key1和key2,a先判断完了执行了exec操作,redis会把key1和key2都清除,这种时候b的操作的安全性就无法保障了。
当然这并非不可解,用于我上面提到的redis的server端是单线程的,那么其实redis除了事务操作的其他命令,本身就是原子的,那么我们就可以考虑将两个限制分开存储,然后直接使用redis的自增操作。redis的自增操作会返回自增之后的值,只要拿着这个值去判断一下,就知道是否存在并发超发了。
但是自增也不是完美的,因为我们不可能把所有用户的限制一直缓存起来,肯定是要有过期时间的,否则即使是redis也可能会被撑爆。基于项目的考量,过期时间设置为每天的0点,即用户第二天就可以再次抽取。而一旦设置了过期时间,那么就可能出现如下情况
情况1:
抽奖活动限制每人最多中奖5次,中奖率100%,这个限制存在于redis中,每天0点过期(redis2.6版之前key的过期延迟为1秒,2.6版本之后过期延迟为1毫秒),那么在key即将过期之前,有一个已经中了3次奖的用户发来了4次请求(不论以何种手段),每次请求均按照该用户已中奖3次通过了中奖限制验证,4个请求均去申请自增中奖限制,而4个请求在去申请中奖限制时时间均超过了该key的过期时间,redis的自增请求会判断为key不存在,并从0开始重新+1,每个自增的请求结果都是不大于抽奖限制的,则该用户最后的限制变为了4,而其实他已经中走了7个奖。
通过incr操作返回自增后的值的特性,可以解决如上问题,即判断自增之后的值是否大于当前已知的限制值,如果不大于,那么说明本次自增是在错误的基础上自增的,应该认定为此次操作用户未中奖,同时删除错误的key, 已确保下一次请求重新计算总限制的值。
情况2:
同样如上的情况,用户只发来了一次请求,在自增操作之前key过期,而此次请求在自增之后判断是否正确的时候,又进来4次请求,该4次请求读取了错误的初始值1,同时进行自增操作,即尽管会被删除一回,但之后的4次会以1作为基础判断,第一个自增操作判断为错误并删除key,但234次操作均通过判断,则用户已中了6个奖。
所以一旦有人发现这一点,在0点时间戳附近时段大量刷新,则可能就会出问题。
针对这一情况,采用两个方法
1. 随机一个10秒到100秒的时间,加到key的过期时间中,避免时间戳直接暴露
2. 每次自增操作增加一次key的TTL判断,如果TTL小于10,则使用expire延长key的生存时间到下一个0点时间戳

更新,看了一遍memcached,觉得果然比redis更适合做缓存,支持多线程就不多说了,CAS操作依赖内建token而不是像redis一样依赖事务就能很完美解决我如上的抽奖需求,最起码会比redis的实现方案优雅很多。

17.11.3更新

实在抱歉没有在发表后仔细查看,我写文章的时候用的两个英文减号,犯懒没有上代码高亮,结果两个减号自动变成了一条横线。。导致执行错误,为大家说声对不起


 

首先来补个上篇redis文章的漏,我们在后台开启了redis,但是如何关闭呢?

假设没有更改端口,那么就使用如下语句:

redis-cli -p 6379 shutdown


下面来进入主题:

当我们应用docker的时候,docker内部的应用是可以直接通过宿主机IP来访问宿主机的,但是这种时候访问redis会被当做是远程访问了,而redis默认是关闭了远程访问的,那么首先我们需要开启,去哪里开启呢?自然还是在redis.conf中,上一篇文章已经介绍了这个文件,可以直接修改,当然我们这里还是使用’–‘语法来直接在启动时候改变。

但是首先还是来理解下redis.conf文件中如何关闭了远程访问,进入redis.conf,我们可以看到如下命令:

bind 127.0.0.1

就是这一条,限定了redis只接收本地端口的访问,看来我们只要更改了它的值就可以改变了,可是不要急,还有大段的注释我们没有看,当然这里我们就只截取对我们这个问题有用的:

# When protected mode is on and if:

#

# 1) The server is not binding explicitly to a set of addresses using the

#    “bind” directive.

# 2) No password is configured.

可以看到redis还存在protected mode,只要这个模式是开着的,就算更改了bind的值,如果没有设定密码的话,也无法开启远程访问:

protected-mode yes

那么如果不想设置密码,改成no就好了

所以直接这么写:

redis-server --bind 0.0.0.0 --protected-mode no  --daemonize yes

而远程访问就可以是:

redis-cli -h your ip -p port you set

可是连密码都不要,你的数据库是只要任何人试出来你的端口就可以随便访问了,这个肯定也是不行的,所以还是要设置个密码,再次去翻看redis.conf,可以看到如下命令被注释掉了

# requirepass foobared

这个词并没有意思,我第一反应是来自官方的恶意,fool + bared,愚蠢的裸露,意思是你们并没有设置密码,也挺有意思。只要解注释这一行,改一下就好了。当然我们会直接这么写:

redis-server --bind 0.0.0.0 --requirepass 1234567 --daemonize yes

已经设置了密码,就不必关闭protected-mode了

远程访问就变成了:

redis-cli -h your ip -p port you set -a your password

参考文献:

redis开启远程访问

redis安全设置

在安装好redis之后,运行src/redis-server命令之后,redis是跑在前台运行的,Ctrl+C之后redis会将内存中的数据保存到硬盘并退出,在本地测试还好,那就多开一个terminal就行了,可是当在服务器上的时候,难道还一直开着一个terminal么?所以我就在思考如何把redis在后台运行,当然这里肯定是指redis server了。于是乎我发现了这篇文章:

Redis的三种启动方式

文章中的第一种方式,即在启动命令后加入’&’的方法并不管用,而我又嫌麻烦并不想去修改配置文件,但我注意到了redis的配置文件也是标准的.conf文件,那么就说明这里可以直接用’–‘语法来在启动的时候来定义参数的值。

所以只要简单的使用如下方法即可令redis在后台启动:

src/redis-server –daemonize yes

可以看到这个参数就是上文的作者给出的第二种方式中需要修改配置文件的那个参数,而相比作者给的方式,我自认为是我的方法是要优雅许多的。

文章很短,所以这里多说一点,就来谈谈程序员的优雅吧。

首先我认为懒和怕麻烦才是程序员进步的第一大动力。

这一个原因为程序员们贡献了无数的语法糖和广为猿知的复用思想。

为什么会有语法糖?一个程序员之所以能够运用甚至写出一个优雅的语法糖,首先他需要对当前的语言有足够的了解,就好比同样的判断赋值有人会去写if else而有人会写三元运算,这两者在编译器识别的层面上来讲没有任何区别,但在我看来明显是三元运算更优雅。

复用就更不用多说了,每个地方写一遍这种做法的愚蠢程度都不用别人来指出,相信程序员自己都会写着写着就会觉得麻烦而开始他的第一次复用之旅。当然如何在大量复用的情况下减少互相之间的依赖,这又是一门学问。盲目的大量复用也许会导致包之间的依赖过深而最终牵一发而动全身,所以复用虽然简单,但想要用好了也同样是需要对语言的了解掌握程度的。

多说了一点程序员的优雅,其实并不是鼓励大家去变懒去怕麻烦,而是要学会自己寻找到更好的道路。

环境:mac版本10.12.3,MySQL旧版本5.7.10

很久之前就是在搭这个blog的时候,在本机安装了MySQL,为了本地测试下,之后就再没有动过。如今转向后端开发,又开始接触MySQL,结果就出了问题,旧版本的MySQL卸载不干净,以至于不论用什么方法装上的新版本MySQL中用户密码版本都不会变,百度了很多办法也无法解决后,最终还是转投了Google(这里倒并不是黑百度,因为最终的解决办法和百度到的很接近,只不过少了两步,估计是搬运的人没搬齐全吧)。

以下是解决方法,一定严格的每一步都执行下去,同时我把我认为的点注释出来:

  • ps ax | grep mysql    //获取mysql的进程,开头的数字就是进程的pid
  • stop and kill any MySQL processes //可以直接kill -9 pid,但是我用上一条查出来的是mysqld的一个进程,kill的时候提示我没有权限,在活动监视器里也无法强制退出,用了如下来自stackoverflow的解决办法:sudo mysqld stop  一定注意关掉mysql相关进程是必须的,我这里查出来是一条,也可能会有不止一条,一定全部关闭
  • brew remove mysql // brew开头这两点是使用homebrew安装的MySQL的选择,如果没有则不用
  • brew cleanup
  • sudo rm /usr/local/mysql // rm命令就是删除命令了,搭配起来删除所有的MySQL相关的文件,会有几条提示没找到相关文件,也不用担心
  • sudo rm rf /usr/local/var/mysql
  • sudo rm rf /usr/local/mysql*
  • sudo rm ~/Library/LaunchAgents/homebrew.mxcl.mysql.plist
  • sudo rm rf /Library/StartupItems/MySQLCOM
  • sudo rm rf /Library/PreferencePanes/My*
  • launchctl unload w ~/Library/LaunchAgents/homebrew.mxcl.mysql.plist
  • vim /etc/hostconfig and remove the line MYSQLCOM=-YES– //这个文件我这里也没有,注意and开始后面是教你如何做的,不要一起复制进去
  • rm rf ~/Library/PreferencePanes/My*
  • sudo rm rf /Library/Receipts/mysql*
  • sudo rm rf /Library/Receipts/MySQL*
  • sudo rm rf /private/var/db/receipts/*mysql*
  • restart your computer just to ensure any MySQL processes are killed // 重启也很重要,都删完之后重启,继续运行第一条看还有没有,不过我这里有一个干扰项,我仍然查到了一条mysql进程,不过并没有运行,但是在活动监视器中搜索已经没有了MySQL进程,这时候就可以重新安装了,大功告成。
  • try to run mysql, it shouldn’t work

至于如何安装MySQL,可以使用homebrew,或者直接MySQL的官网下载,我的新版本即是在官网下载的,其实我总觉得官网下载的installer稳妥点,会配置的稍微好点,当然也可能是心理作用。

参考文献:

删除MySQL:

Uninstall all those broken versions of MySQL

How to stop mysqld – stackoverflow

安装MySQL:

官网:MySQL Website 推荐dmg的版本