无锁缓存,每秒10万并发,究竟如何实现?

有一类业务场景:

(1)超高吞吐量,每秒要处理海量请求;

(2)写多读少,大部分请求是对数据进行修改,少部分请求对数据进行读取;


这类业务,有什么实现技巧么?


接下来,一块儿听我从案例入手,娓娓道来。数据库


快狗打车场景举例
(1)司机地理位置信息会随时变化,可能 每几秒钟地理位置要修改一次
(2)用户打车的时候查看某个司机的地理位置, 查询地理位置的频率相对较低

这里要用到两个接口
(1)大量修改司机信息:
void SetDriverInfo(long driver_id, DriverInfo info);
(2)相对少许查询司机信息:
DriverInfo GetDriverInfo(long driver_id); 

这一类业务,通常怎么实现呢?
具体到底层的实现,每每是 一个Map内存缓存
(1)查询key定长,例如:司机ID;
(2)返回value也定长,例如:司机实体序列化后的二进制串;

即,相似这样的一个kv缓存结构:
Map<driver_id, DriverInfo>

这个kv内存缓存是一个临界资源,对它的并发访问,有什么注意事项么?
临界资源的访问, 须要注意加读写锁,实施互斥

如下,是加锁写入的伪代码:

void SetDriverInfo(long driver_id, DriverInfo info){缓存

         WriteLock (m_lock);微信

         Map<driver_id>= info;多线程

         UnWriteLock(m_lock);架构

}并发

画外音:假设info已经序列化。

如下,是加锁读取的伪代码:

DriverInfo GetDriverInfo(long driver_id){app

         DriverInfo t;运维

         ReadLock(m_lock);高并发

         t= Map<driver_id>;性能

         UnReadLock(m_lock);

         return t;

}


当吞吐量很高时,上述流程可能存在什么问题?
假设快狗打车有100w司机同时在线,每一个司机每5秒更新一次经纬度状态,那么 每秒就有20w次写并发操做

假设快狗打车日订单1000w个,平均每秒大概也有300个下单,对应到查询并发量,大概 每秒1000级别的并发读操做

在这样的吞吐量下(每秒20w写,1k读), m_lock会成为潜在瓶颈 ,致使Map访问效率极低。

有什么潜在的优化方法么?

锁冲突之因此严重,是由于整个Map共用一把锁,锁的粒度太粗。

画外音:能够认为是一个数据库的“库级别锁”。


是否可能进行水平拆分,来下降锁冲突呢?

答案是确定的。

画外音:相似于数据库里的分库,把一个库锁变成多个库锁,来提升并发,下降锁冲突。


咱们能够把1个Map水平切分红N个Map

void SetDriverInfo(long driver_id, DriverInfo info){

         i = driver_id % N; // 水平拆分红N份,N个Map,N个锁

         WriteLock (m_lock[i]);  //锁第i把锁

         Map[i]<driver_id>= info;  // 操做第i个Map

         UnWriteLock (m_lock[i]); // 解锁第i把锁

}


如此优化,可否提升性能?
(1)一个Map变成了N个Map, 每一个Map的并发量,变成了1/N
(2)同时, 每一个Map的数据量,变成了1/N
因此理论上,锁冲突会成平方指数下降,性能会提高。

有没有可能,进一步细化锁粒度,一个元素一把锁呢?

答案也是确定的。

画外音:能够认为是一个数据库的“库级别锁”,优化为“行级别锁”。


不妨设driver_id是递增生成的,而且假设内存比较大,此时能够把Map优化成Array,并把锁的粒度细化到最细的,每一个司机信息一个锁:

void SetDriverInfo(long driver_id, DriverInfo info){

         index = driver_id;

         WriteLock (m_lock[index]);  //超级大内存,一条记录一个锁,锁行锁

         Array[index]= info; //driver_id就是Array下标

         UnWriteLock (m_lock[index]); // 解锁行锁

}


这个方案使得锁冲突降到了最低,但锁资源大增,在数据量很是大的状况下,内存每每是装不下的。
画外音:数据量比较小的时候,能够一个元素一把锁,典型的是链接池,每一个链接用一把锁表示链接是否可用。

尚未方法进一步下降锁冲突,提高并发量呢?

写多读少的业务,有一种优化方案无锁缓存,将锁冲突下降到。


无锁缓存,可能存在什么问题?
若是缓存不加锁,读写吞吐量能够达到极限,可是多线程对缓存中同一块定长数据进行写操做时, 有可能出现不一致的脏数据

这个方案为了提升性能,牺牲了一致性。

读取时, 获取到了错误的数据,是不能接受的
画外音:做为缓存,容许 cache miss ,却不容许读脏数据。

脏数据是如何产生的?
不加锁,在多线程并发写时,可能出现如下状况:

(1)线程1对缓存进行操做,对 key 想要写入 value1
2)线程2对缓存进行操做,对 key 想要写入 value2
3)不加锁,线程1和线程2对同一个定长区域进行一个并发的写操做, 可能每一个线程写成功一半,致使出现脏数据产生 ,最终的结果即不是 value1 也不是 value2 ,而是一个乱七八糟的不符合预期的值 value-unexpected

如何解决上述问题呢?
本质上,这是一个数据完整性问题。

并发写入的数据分别是 value1 value2 ,读出的数据是 value-unexpected ,数据被篡改,这本质上是一个数据完整性的问题。

一般如何保证数据的完整性呢?

例如:运维如何保证,从中控机分发到上线机上的二进制没有被篡改?
md5。

又例如:即时通信系统中,如何保证接受方收到的消息,就是发送方发送的消息?
发送方除了发送消息自己,还要发送消息的签名 ,接收方收到消息后要校验签名,以确保消息是完整的,未被篡改。

“签名”是一种常见的保证数据完整性的方案。

加入“签名”保证数据的完整性以后,读写流程须要如何升级?

加上签名以后, 不但缓存要写入定长value自己,还要写入定长签名 (例如 16bitCRC 校验):
(1)线程1对缓存进行操做,对 key 想要写入 value1 ,写入签名 v1-sign
2)线程2对缓存进行操做,对 key 想要写入 value2 ,写入签名 v2-sign
3)若是不加锁,线程1和线程2对同一个定长区域进行一个并发的写操做,可能每一个线程写成功一半,致使出现脏数据产生,最终的结果即不是 value1 也不是 value2 ,而是一个乱七八糟的不符合预期的值 value-unexpected 但签名,必定是v1-sign或者v2-sign中的任意一个
画外音:16bit/32bit的写能够保证原子性。
4)数据读取的时候,不但要取出 value ,还要像消息接收方收到消息同样,校验一下签名,若是发现签名不一致,缓存则返回 NULL ,即 cache miss

固然,对应到司机地理位置,除了内存缓存以前,确定须要timer对缓存中的数据按期落盘,写入数据库,若是cache miss,能够从数据库中读取数据。

巧不巧秒?

总结

当业务知足:
(1)超高并发
(2)写多读少
(3)定长value
时,能够用如下方法来提高吞吐量:
(1)水平拆分来下降锁冲突;
思路 单库变多库。

2)Map转Array的方式来最小化锁冲突,一条记录一个锁;
思路 库锁变行锁。

3)无锁,最大化并发;
思路 行锁变无锁,完整性与性能的折衷。

4)经过签名的方式保证数据的完整性,实现无锁缓存;
思路 写时写签名,读时校验签名。

若是你喜欢本文,大几率会喜欢这个架构训练营,欢迎一块儿来玩。
扫码,一块儿玩架构,学别处没有的知识

思路比结论重要 ,但愿你们有收获,谢
阅读原文 ,更多干货。

本文分享自微信公众号 - 架构师之路(road5858)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索