高效定时器的实现
场景
linux crontab在实现定时任务中起到重要的作用,那如果我们去实现定时一个contab又如何实现呢?怎么样才能高效呢?本文会介绍Nginx内部的定时器的实现和golang的Tick定时器的实现。
常规思路
在一个死循环里每秒(精度为秒)去轮询查看有没有到期的任务需要执行。
问题一
有必要每秒轮询一次吗?下一秒、下下一秒、下下下一秒都不一定会有到期执行的任务,那样不就浪费了CPU了,为何不一直休眠到最近一个到期的任务的时间点,再去遍历呢?
问题二
用什么样的数据结构去存储所有任务,才能起到高效的轮询呢?用数组?每次都遍历一遍?很明显,不高效。在Nginx里,使用了红黑树,golang中使用了小堆。
golang tick实现
|
|
golang用一个timer结构体来表示一个定时任务,其中when表示到期的绝对时间戳,也是用来进行排序构建小堆的关键字段。这样子,最先到期的任务,就会是小堆堆头的那个节点了。period字段用来表示是否循环执行这个定时任务,如果是循环任务,执行完后会重新修改when为下一次执行的时间点,调整小堆。否则,把定时任务从小堆中删除。
|
|
|
|
Nginx定时器
Nginx运用定时器的地方很多,例如读取http头部超时。Nginx使用红黑树维护所有定时任务事件,进程在每次事件轮询返回后,都会检查一遍红黑树,处理过期的定时事件,设置ngx_event_t结构体里的timedout字段为1。
|
|
Nginx woker进程检查处理过期的定时事件
1234567891011121314151617181920212223242526272829303132// worker的事件和定时任务处理函数voidngx_process_events_and_timers(ngx_cycle_t *cycle){ngx_uint_t flags;ngx_msec_t timer, delta;// ...delta = ngx_current_msec;(void) ngx_process_events(cycle, timer, flags);delta = ngx_current_msec - delta;ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,"timer delta: %M", delta);// 事件处理ngx_event_process_posted(cycle, &ngx_posted_accept_events);if (ngx_accept_mutex_held) {ngx_shmtx_unlock(&ngx_accept_mutex);}if (delta) {// 处理过期的定时任务ngx_event_expire_timers();}// ...}ngx_event_expire_timers过期定时任务处理
1234567891011121314151617181920212223242526272829303132333435voidngx_event_expire_timers(void){ngx_event_t *ev;ngx_rbtree_node_t *node, *root, *sentinel;sentinel = ngx_event_timer_rbtree.sentinel;// 循环查找处理当前所有过期的时间for ( ;; ) {root = ngx_event_timer_rbtree.root;if (root == sentinel) {return;}node = ngx_rbtree_min(root, sentinel); // 在红黑树种查找当前时间最小的节点if ((ngx_msec_int_t) (node->key - ngx_current_msec) > 0) {// 如果时间最小的节点都还没过期,直接返回return;}ev = (ngx_event_t *) ((char *) node - offsetof(ngx_event_t, timer));ngx_log_debug2(NGX_LOG_DEBUG_EVENT, ev->log, 0,"event timer del: %d: %M",ngx_event_ident(ev->data), ev->timer.key);ngx_rbtree_delete(&ngx_event_timer_rbtree, &ev->timer); // 把时间从红黑树种删除ev->timer_set = 0;ev->timedout = 1; // 把时间设为过期ev->handler(ev); // 回调事件处理函数}}回调事件处理函数,拿http头部处理函数(ngx_http_process_request_headers)来举例吧
12345678910111213141516171819202122static voidngx_http_process_request_headers(ngx_event_t *rev){// ...c = rev->data;r = c->data;ngx_log_debug0(NGX_LOG_DEBUG_HTTP, rev->log, 0,"http process request header line");// 如果过期了,在上一步ngx_event_expire_timers中,timedout字段会被设为1,表示头部处理超时了,就给客户端错误提示if (rev->timedout) {ngx_log_error(NGX_LOG_INFO, c->log, NGX_ETIMEDOUT, "client timed out");c->timedout = 1;// 超时,关闭链接,发送request timeout错误ngx_http_close_request(r, NGX_HTTP_REQUEST_TIME_OUT);return;}// ...}
总结
实现一个定时任务调度器,需要一个进程(线程或者协程) + 一个高效数据结构(满足高效查询、频繁插入删除),例如Nginx使用的红黑树、golang使用的小堆,或者skip list(跳跃表),跳跃表是有序链表,同时插入、删除、查找性能效率也不俗,实现起来还容易,可以考虑下。
concurrentcache--golang内存缓存
concurrentcache是什么
concurrentcache是golang版的内存缓存库,采用多Segment设计,支持不同Segment间并发写入,提高读写性能。
设想场景
内存缓存的实现,防止并发写冲突,都需要先获取写锁,再写入。如果只有一个存储空间,那么并发写入的时候只能有一个go程在操作,其他的都需要阻塞等待。为了提高并发写的性能,把存储空间切分成多个Segment,每个Segment拥有一把写锁,那样,分布在不同Segment上的写操作就可以并发执行了。concurrentcache设计就是切分多个Segment,提高些并发写的效率。
concurrentcache设计思路
|
|
写
从ConcurrentCache结构体可以看出,包含一个ConcurrentCacheSegment切片,sCount表示切片的长度,这个切片的长度,在初始化的时候就确定,不再改变。
ConcurrentCacheSegment内包含一个读写锁,写入时候根据key的hash值选取写入到哪一个Segment内,通过多Segment设计,来提高写并发效率。
读
为了提高读效率,尽可能多的减少读过程对内存缓存的修改,使用golang提供的原子操作来修改访问状态(visit、miss、hits等),遇到缓存过期的节点也不马上淘汰,因为读访问上的是读锁,要删除数据需要用到写互斥锁,这样会降低读的并发性,所以推迟删除过期的节点,当写数据,Segment的节点不够用的时候再去删除过期节点。
concurrentcache缓存淘汰方式
concurrentcache缓存淘汰方式采用随机选取3个节点,优先淘汰其中过期的一个节点(上一步说的推迟删除过期节点),如果没有过期节点就选取访问量最少(ConcurrentCacheNode结构体中的visit)的节点淘汰。这个思路是参考redis的缓存淘汰策略,redis并不是严格lru算法,采用的是随机选取样本的做法。这样做也是为了提高写性能。
concurrentcache & cache2go 性能对比
(MBP 16G版本)
concurrentcache和cache2go进行了并发下的压测对比,对比结果,concurrentcache无论是执行时间还是内存占比,都比cache2go优
concurrentcache
|
|
cache2go
|
|
PHP扩展--ratelimit本地服务器限流
应用限流
本文讲的应用限流,是限制单位时间PHP应用服务器总访问量,不针对接口,不针对个人用户。原做法,是通过Redis记录访问总量,通过过期时间淘汰(单位时间访问量清零)。做法简单,效果也不错,但缺点也明显,一次访问量判断和设置,就至少要一次网络开销(Redis Incr)。优化的思路是,可否省掉这一次Redis操作(直接去掉不限流了hahahaha)。限制总访问量,分摊到每台应用机上,不就是它们自己的指标了吗。对,那就应用服务器本地记录状态,本地判断。那本地怎么记录状态?多着,本地文件、APC缓存。但要考虑性能问题,本地文件就…当我没说过。APC不错,实现起来会复杂点,要考虑修改记录的冲突问题。
Redis 版本
下面给个Redis的简版,官方思路,但有bug
Redis的版本可以setnx锁来实现会更稳
我的限流PHP扩展
开头说,使用APC来实现Redis上的这个功能是可以的,只是要多的事会多点,不说了,说说APC缓存的原理吧,APC使用内存共享的来实现进程间通信,咋说,php-fpm是多进程模型,同一进程可以处理多次(不是同时处理)php请求,换句话说,php-fpm的worker进程是常驻内存的,那么,php-fpm woker进程就可以通过内存共享来达到缓存数据的功能。
那么利用内存共享不就可以在多个进程间共享本地服务器的访问量了,就可以保存访问记录了。
扩展源码
|
|
PHP并发查询MySQL
同步查询
这是我们最常的调用模式,客户端调用Query[函数],发起查询命令,等待结果返回,读取结果;再发送第二条查询命令,等待结果返回,读取结果。总耗时,会是两次查询的时间之和。简化一下过程,例如下图:
例图,由1.1到1.3为一个Query[函数]的调用,两次查询,就要串行经历1.1、1.2、1.3、2.1、2.2、2.3,尤其在1.2和2.2会阻塞等待,进程没法做其他事情。
同步调用的好处是,符合我们的直观思维,调用和处理都简单。缺点是进程阻塞在等待结果返回,增加额外的运行时间。
如果,有多条查询请求,或者进程还有其他的事情处理,那么能否把等待的时间也合理利用起来,提高进程的处理能力呢,显然是可以的。
拆分
现在,我们把Query[函数]打碎,客户端在1.1后,马上返回,客户端跳过1.2,在1.3有数据达到后再去读取数据。这样进程在原来的1.2阶段就解放了,可以做更多的事情,例如…再发起一条sql查询[2.1],是否看到了并发查询的雏形了。
并发查询
相对于同步查询的下一条查询的发起都在上一条完成后,并发查询,可以在上一条查询请求发起后,立刻发起下一条查询请求。简化一下过程,下图:
例图,在1.1.1成功发送完请求后,立马返回[1.1.2],最终查询结果的返回时在遥远的1.2 。但是在,1.1.1到1.2中间,还发起了另一个查询请求,这时间段内,就同时发起了两条查询请求,2.2先于1.2到达,那么两条查询的总耗时,只相当于第一条查询的时间。
并发查询的优点是,可以提高进程的使用率,避免阻塞等待服务器处理查询,缩短了多条查询的耗时。但缺点也很明显,发起N条并发查询,就需要建立N条数据库链接,对于有数据库连接池的应用来说,可以避免这种情况。
退化
理想情况下,我们希望并发N条查询,总耗时等于查询时间最长的一条查询。但也有可能并发查询会[退化]为[同步查询]。What?例图中,如果1.2在2.1.1前就返回了,那么并发查询就[退化]为[同步查询]了,但付出的代价却比同步查询要高。
多路复用
- 发起query1
- 发起query2
- 发起query3
- ………
- 等待query1、query2、query3
- 读取query2结果
- 读取query1结果
- 读取query3结果
那么,怎么等待知道什么时候查询结果返回了,又是哪个的查询结果返回呢?
对每个查询IO调用read?如果是遇上阻塞IO,这样就会阻塞在一个IO上,其他IO有结果返回了,也没法处理。那么,如果是非阻塞IO,那不用怕会阻塞在其中一个IO上了,确实是,但又会造成不断地轮询判断,浪费CPU资源。
对于这种情况可以使用多路复用轮询多个IO。
PHP实现并发查询MySQL
PHP的mysqli(mysqlnd驱动)提供多路复用轮询IO(mysqli_poll)和异步查询(MYSQLI_ASYNC、mysqli_reap_async_query),使用这两个特性实现并发查询,示例代码:
mysqli_poll源码:
并发查询操作结果
为了更直观地看效果,我找了一个1.3亿数据量并且没有优化过的表进行操作。
并发查询的结果:
同步查询的结果:
从结果来看,同步查询的总耗时是所有查询的时间的累加;而并发查询的总耗时在这里其实是查询时间最长的那一条(同步查询的第四条,耗时是10几秒,符合并发查询的总耗时),而且并发查询的查询顺序和结果到达的顺序是不一样的。
多条耗时较短的查询对比
使用多条查询时间较短的sql进行对比一下
并发查询的测试1结果(数据库链接时间也统计进去):
同步查询的结果(数据库链接时间也统计进去):
并发查询的测试2结果(不统计数据库链接时间):
从结果上看,并发查询测试1并没有讨到好处。从同步查询上看,每条查询耗时大概3-4ms左右。但如果不把数据库链接时间统计进去(同步查询只有一次数据库链接),并发查询的优势又能体现出来了。
结语
这里探讨了一下PHP实现并发查询MySQL,从实验上结果直观地认识了并发查询的优缺点。建立数据库连接的时间在一条优化了的sql查询上,占得比重还是很大。#没有连接池,要你何用
github:PHP并发查询MySQL代码
Redis AOF
AOF
AOF是Redis的一种持久化方式,另一种是RDB。AOF通过追加写命令到文件实现持久化存储,Redis需要恢复的数据,按顺序逐条执行文件中的命令来实现恢复。
追加写命令到AOF文件
Redis执行完写命令后,把命令添加到aof_buf缓冲区中,在下一次事件轮询开始前(或者定时任务)根据指定的策略,把aof_buf缓冲区的命令写入到文件,刷新到磁盘。
appendfsync always 总是把缓冲区的数据写入文件并调用系统的fsync方法刷新到磁盘
appendfsync everysec 把缓冲区的数据写入文件,每秒执行一次fsync方法刷新到磁盘
appendfsync no 把缓冲区的数据写入文件,不会主动调用fsync方法刷新到磁盘,交由操作系统处理
第一:执行完写命令后,把命令添加到aof_buf缓冲区中:
第二:根据指定的策略,把aof_buf缓冲区的命令写入到文件,刷新到磁盘
AOF重写
随着写命令不断累积,AOF文件会不断地增长,这时候Redis会执行重写,合并一些命令,举个例子,连续多条incr key命令,最终都可以合并成一条set key num的命令,大大地减少AOF文件的大小。同时,命令减少了,也可以提高Redis恢复的速度。
如何重写
AOF重写,是通过把Redis内存中的数据“翻译”成一条条命令写入到新AOF文件,而不是拿旧的AOF文件做文章,那如何“翻译”呢,看源码说事。
启动一个子进程,有子进程执行AOF文件重写
重写过程如何处理新到达的写命令
重写过程中,父进程会继续接收客户端命令,并把新的命令通过管道发送给子进程。
重写完成后
子进程重写完AOF文件后,就退出,由父进程做收尾的工作,例如:在子进程重写的最后会要求父进程停止发送最新命令,这之后AOF重写缓冲区还存在或者又积累了客户端发送过来的新命令,这些都由父进程来重写到新的AOF文件。
Redis源码——内存淘汰机制
Redis缓存淘汰策略
noeviction:内存达到了上限,不淘汰内存数据,遇到大部分写命令返回Out Of Memory错误。
allkeys-lru:在所有的key的哈希表中随机选择多个key,在选择到的key中使用lru算法淘汰最近最少使用的缓存。
volatile-lru:在设置了过期时间的哈希表里面随机选择多个key,在选择到的key中使用lru算法淘汰最近最少使用的缓存。
allkeys-random:在所有的key的哈希表中随机选择一个key淘汰掉。
volatile-random:在设置了过期时间的哈希表里面随机选择一个key淘汰掉。
volatile-ttl:在设置了过期时间的哈希表里面随机选择多个key,在挑选到的key中选择过期时间最小的一个淘汰掉。
LRU算法
lru算法原理,根据数据的访问时间,选择淘汰最长时间未被使用的数据。可以使用链表来实现,新增数据(访问数据),把这数据插入(移动)到链表头部,淘汰数据就选择链表末尾的数据淘汰掉。
Redis采样淘汰
Redis实现的lru淘汰算法,选择被淘汰的key不一定是所有key中最近最少使用的,只是选取的样本中访问时间距离当前时间最远的。Redis有个maxmemory-samples配置项,当Redis内存使用达到上限后,从对应哈希表中挑选设置样本数量的key,插入或者替换到样本集中,最后对样本集使用lru算法,淘汰最长时间未被使用的数据。
Redis在随机采样集中应用lru淘汰数据,而不是类似memcached那样严格地挑选所有key中最近最少访问的数据。第一是考虑到Redis内部db的实现方式,Redis是使用hash表结构实现key的存储,要实现严格的lru,那么Redis就要额外的实现一个访问时间链表,增加内存开销(Redis对内存使用还是很抠门的,很多地方都牺牲时间来换取更少的空间开销),这说法是来自官网的,并且每次读写操作都需要维护更新访问时间链表;第二考虑操作的时间开销,实现严格的lru算法,那样淘汰数据的时候都需要扫描整个哈希表(前提是基于当前的数据结构来说),扫描所有key,这样付出的时间开销不言而喻。
Redis缓存淘汰源码分析
|
|
Redis主从复制——Slave视角
Redis主从复制
为了提高性能和系统可用,Redis都会做主从复制,一来可以分担主库压力,二来在主库挂掉的时候从库依旧可以提供服务。Redis的主从复制是异步复制,返回结果给客户端和同步命令到从库是两回事,互不相干,主库也不关心从库的执行结果,对于同步命令执行的结果,从库会直接丢弃并不返回给主库。Redis的主从复制简单高效,但也不太算可靠。
Redis的主从复制是异步复制;全量同步(或增量同步)+命令传播
Slave Server
流程
Slave Server启动初始化配置,根据slaveof配置设置Slave Server的主库host(masterhost)和Slave Server的同步状态(repl_state),和所有Server一样监听客户端链接,开启后台任务。
后台定时任务包含,触发AOF重写、RDB快照、redis监控、状态收集、主从同步相关定时任务等
主从同步后台定时任务包含,从库连接主库、从库重连主库、从库给主库发送同步进度、主库向从库发送心跳包、主库删除超时从库、主库清除同步缓冲区、主库刷新从库状态等
从库连接主库
从库链接主库后,开启同步前的准备和交互,同时从库伴随着和主库交互变换自身状态。下面源代码看一下整个流程(代码有删减)
以上代码有点长,总结一下步骤:1)slave发送自身信息到master;2)尝试增量同步,成功则等待master回送同步数据;3)不支持增量同步或者增量同步失败,则进行全量同步,并等待master回送同步数据。
全量同步(主库基于Disk-backed模式)
1、从库发送sync命令给主库(2.8以上Redis直接使用psync命令)发起全量同步,并等待数据返回
2、主库接收到命令(或者尝试增量同步失败后),把内存数据保存到rdb文件并把文件内容发送给从库
3、从库接收同步数据并保存到本地rdb文件,最后把rdb文件内容写入到内存数据库,至此全量同步完成
以上是简述一下同步流程,但其中并不止那么简单,例如:同时多个slave发起全量同步请求,主库也只会进行一次bgsave,保存内存快照到rdb文件。
rdb是redis持久化的一种,是当前redis内存数据的快照。所以全量同步,主库发送给从库的是,是内存中每一个key和它对应的value(key => value)。
以下源码分析第三步操作(代码有删减)
增量同步
全量同步可以看出存在它的缺点,那就是效率,主从都要进行文件读写,而且还要传输全部数据,其中部分已经在从库中,这部分数据就会显得有点多余了。增量同步,同步的内容也和全量同步有所区别。
全量同步,同步的数据是每一个key和它对应的value(key => value);而增量同步,同步的数据是命令,是从库未执行的命令,例如:set key1 value1 。
执行增量同步的条件:
1)从库已经和该主库进行过一次全量同步
2)主从网络超时(repl_timeout默认是60秒)重连后
3)从库同步进度(reploff)存在并且没有落后于该主库同步缓冲区
|
|
命令传播
完成全量同步(增量同步)后,主库接受客户端命令,修改了数据,为了保持主从数据一致,这些命令也需要在从库上执行一遍,哪怎么操作呢?当然不是客户端逐一修改所有从库了,而是由主库执行命令成功后,异步地把命令发送给所有从库。
这部分主要工作在于主库,就不说了,下一篇主库角度再说。从库接收主库的命令传播,其实和其他客户端的命令一样的行为,只是从库会判断是否来自主库发送的命令,更新自己同步进度,主库不关心从库执行命令的结果,所以从库也不会发送执行结果给主库,省略了一次网络IO。
心跳检测
开篇我们说过,Redis会有个定时任务在后台执行,从库会每秒向主库发送ack+同步进度;主库也会定时发送PING命令检测它所有从库的存活。
风险
Redis的主从复制是很高效,也没有太多花哨的东西,基于异步同步,客户端不需要等待同步结果,但是也是这样的高效同步带来一些风险。
1)主库发送仅且发送一次命令给从库,如果超时,命令丢失,从库没有接收到,会造成不一致;
2)从库内存满了,主库也没法知道,而且从库收到命令传播依旧会更新自己同步进度;
3)异步复制带来的同步间隙,造成短时间内不一致,这点要根据具体业务处理;
4)客户端修改从库数据,也会导致主从不一致,可以把从库设置成只读;
废话
从库未全量同步过
Slave:Master,我要增量同步(psync ? -1)
Master:EXO ME?你没全量同步过,不行,你必须全量同步
Slave:好的,师父。全量同步
Master:同意。等着吧。
(Slave等啊等)
Master:接着,rdb
Slave:收!
(Slave全量同步完后,上线工作了,命令传播)
Master:这是我刚执行完的命令,你执行一下
Slave:好的(执行完了也不告诉你)
Master:这是我刚执行完的命令,你执行一下
Slave:好的(执行完了也不告诉你)
……
主从网络断开重连后
Slave:师父,刚掉线了,我是不是错过了什么,我要增量同步一下(psync Master_id repl_off)
Master:刚哪浪去了?问你又不答我。行了,增量同步一下吧,等着
Slave:好的,师父。
(Slave等啊等)
Master:接着,这些都是你刚没有执行的命令
Slave:收!
(Slave增量同步完后,上线工作了,命令传播)
Master:这是我刚执行完的命令,你执行一下
Slave:好的(执行完了也不告诉你)
Master:这是我刚执行完的命令,你执行一下
Slave:好的(执行完了也不告诉你)
……
主从网络断开重连后,slave落后太多
Slave:师父,刚掉线了,我是不是错过了什么,我要增量同步一下(psync Master_id repl_off)
Master:刚哪浪去了?问你又不答我。不行不行,你落后了(repl_off进度太旧了),你先全量一下我们在聊吧
Slave:好的,师父。全量同步
Master:同意。等着吧。
(Slave等啊等)
Master:接着,rdb
Slave:收!
(Slave全量同步完后,上线工作了,命令传播)
Master:这是我刚执行完的命令,你执行一下
Slave:好的(执行完了也不告诉你)
Master:这是我刚执行完的命令,你执行一下
Slave:好的(执行完了也不告诉你)
……
PHP源码阅读strtr
strtr
转换字符串中特定的字符,但是这个函数使用的方式多种。
时间复杂度
O(n),最差是O(n*m)
源码
以下根据每种情况逐一分析源码。
第一种、第二种,也是最常用的,但第二种,只有’h’转换成’a’,’w’没有被处理。这种方式的替换,会以短的一方为准。如果from和to其中一个是空串,会直接返回原字符串。
接着,我们主要看下php_strtr_ex方法,是怎么实现字符转换。源码是使用hash表实现,hash表把from的每个字符,一一对应为to的相应位置的字符。
第三种、第四种from是个数组,如果from是数组,情况就不是一对一的字符转换,是字符串对字符串的转换了,把key整个字符串转换成value字符串。
第三种,from数组只有一对键值对,实现思路是,根据kmp算法在主串中搜索key(被替换的字符串)的位置,如果找到,就使用value替换掉。kmp本身的效率是O(n),所以如果字符串内进行了m次替换,这种情况下strtr效率会是O(n*m)
第四种,通过数组替换多个字符串,这种是各种情况效率最差的
这种情况有点复杂,下面的php伪代码翻译一下以上的C语言代码
从抽奖活动总结系统优化
在魅族工作的时间,主要是从事抽奖活动、投票活动等网络营销活动,尤其是抽奖活动,魅族营销的成本投入还是大的,所以每次活动,参与用户都很热情,刚开始那会每次活动上线都会战战兢兢,现在是老油条了,不是说不怕,只是有经验了。应对并发的三板斧,限流、缓存、异步,下面就简单说说自己一些经验吧。
代码
检查代码有没有写出多层foreach嵌套,考虑是否有改进的空间;使用锁的地方考虑有没有死锁的可能性;请求第三方接口是否会漏掉超时限制,这点很容易被忽略,对接第三方的接口需要多点留意。
限流
大考前的检查,压测,通过压测的数据指导,调整nginx最大链接数,系统能力有限,超过最大链接数的直接拒绝请求。
限制僵尸用户,我们系统是微信授权的,使用微信的用户验证接口,可以挡掉一部分僵尸用户,联合自身的用户系统,排除刷奖用户的请求,这部分可以减少大部分流量。
应用层限制请求频率,限制单个用户一分钟内请求次数,使用redis的hash表存储用户一分钟内的请求次数,一个用户一条记录。
缓存
应用场景
对于数据量小、更新不频繁的,例如活动信息等,可以考虑用本地缓存,同时也使用redis做二级缓存,提高命中,提高缓存效率;数据量大、更新频繁、增长快的缓存数据使用redis等专用缓存服务。例如:随机获取愿望墙记录列表,通过数据库去随机获取那简直是噩梦,可以用redis的集合保存所有记录id,通过SRANDMEMBER从集合中返回随机id集,再通过id集从redis的hash表中获取每条记录的详细信息。
缓存更新
主动更新缓存,数据库数据更新后,往消息队列插入记录,后台消费程序主动更新缓存;同时添加缓存过期时间,自动淘汰缓存,双保险。
缓存击穿
缓存丢失的瞬间,并发请求下,会瞬间有多个请求到达数据库,查询同一份数据,数据库压力瞬间暴涨,解决方法,可以在请求数据库前上锁(使用redis setnx实现锁),只允许获得锁更新缓存,没得到锁的等待缓存更新后,直接从缓存获得数据。
数据库
慢查询优化
我们系统平时都会进行压测,根据压测结果和慢查询日志进行sql优化,这点展开说太多了。
分库
我们有时候会同时上几个活动,一般都把不同的活动分摊到不同的数据库上,相当于把流量分摊到不同数据库上。
主从同步
我们配有主从库,主写从读,在主从同步间隙,会遇到从库未同步完成,客户端读不到最新数据,可以在写入主库成功后,往redis插入一条记录,设置过期时间(一般为主从同步需要的时间),读取前,先读取redis,如果记录存在,就从主库返回数据。
乐观锁
抽奖活动扣除奖品数量,在并发下,如果不加保护,很容易超库,一般都使用事务进行扣除,但事务会影响数据性能,我们做法是使用版本号,更新库存的时候,只有版本号对上才能更新成功。
消息队列
对于抽奖活动,前端有转盘等过场动画的,可以使用异步的方式,达到削峰填谷的效果;投票这些不需要实时反馈结果的,也可以使用消息队列进行异步处理,消费程序在后台批量处理。使用消息队列需要保证消息不丢失,和防止重复消费消息。