[笔记备忘]无锁缓存

http://mp.weixin.qq.com/s/bu86p2IcOFmHW5TJ9Xc_2A

一、需求缘起
【业务场景】
有一类写多读少的业务场景:大部分请求是对数据进行修改,少部分请求对数据进行读取。
例子1:滴滴打车,某个司机地理位置信息的变化(可能每几秒钟有一个修改),以及司机地理位置的读取(用户打车的时候查看某个司机的地理位置)。
void SetDriverInfo(long driver_id, DriverInfoi); // 大量请求调用修改司机信息,可能主要是GPS位置的修改
DriverInfo GetDriverInfo(long driver_id); // 少量请求查询司机信息

例子2:统计计数的变化,某个url的访问次数,用户某个行为的反作弊计数(计数值在不停的变)以及读取(只有少数时刻会读取这类数据)。
void AddCountByType(long type); // 大量增加某个类型的计数,修改比较频繁
long GetCountByType(long type); // 少量返回某个类型的计数

【底层实现】
具体到底层的实现,往往是一个Map(本质是一个定长key,定长value的缓存结构)来存储司机的信息,或者某个类型的计数。
Map<driver_id, DriverInfo>
Map<type, count>

【临界资源】
这个Map存储了所有信息,当并发读写访问时,它作为临界资源,在读写之前,一般要进行加锁操作,以司机信息存储为例:
void SetDriverInfo(long driver_id, DriverInfoinfo){
WriteLock (m_lock);
Map<driver_id>= info;
UnWriteLock(m_lock);
}

DriverInfo GetDriverInfo(long driver_id){
DriverInfo t;
ReadLock(m_lock);
t= Map<driver_id>;
UnReadLock(m_lock);
return t;
}

【并发锁瓶颈】
假设滴滴有100w司机同时在线,每个司机没5秒更新一次经纬度状态,那么每秒就有20w次写并发操作。假设滴滴日订单1000w个,平均每秒大概也有300个下单,对应到查询并发量,可能是1000级别的并发读操作。
上述实现方案没有任何问题,但在并发量很大的时候(每秒20w写,1k读),锁m_lock会成为潜在瓶颈,在这类高并发环境下写多读少的业务仓井,如何来进行优化,是本文将要讨论的问题。

二、水平切分+锁粒度优化
上文中之所以锁冲突严重,是因为所有司机都公用一把锁,锁的粒度太粗(可以认为是一个数据库的“库级别锁”),是否可能进行水平拆分(类似于数据库里的分库),把一个库锁变成多个库锁,来提高并发,降低锁冲突呢?显然是可以的,把1个Map水平切分成多个Map即可:
void SetDriverInfo(long driver_id, DriverInfoinfo){
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****把锁
}

每个Map的并发量(变成了1/N)和数据量都降低(变成了1/N)了,所以理论上,锁冲突会成平方指数降低。
分库之后,仍然是库锁,有没有办法变成数据库层面所谓的“行级锁”呢,难道要把x条记录变成x个Map吗,这显然是不现实的。

三、MAP变Array+最细锁粒度优化
假设driver_id是递增生成的,并且缓存的内存比较大,是可以把Map优化成Array,而不是拆分成N个Map,是有可能把锁的粒度细化到最细的(每个记录一个锁)。
void SetDriverInfo(long driver_id, DriverInfoinfo){
index= driver_id;
WriteLock (m_lock [index]); //****超级大内存,一条记录一个锁,锁行锁
Array[index]= info; //driver_id就是Array下标
UnWriteLock (m_lock[index]); // ****解锁行锁
}

和上一个方案相比,这个方案使得锁冲突降到了最低,但锁资源大增,在数据量非常大的情况下,一般不这么搞。数据量比较小的时候,可以一个元素一个锁的(典型的是连接池,每个连接有一个锁表示连接是否可用)。

上文中提到的另一个例子,用户操作类型计数,操作类型是有限的,即使一个type一个锁,锁的冲突也可能是很高的,还没有方法进一步提高并发呢?

四、把锁去掉,变成无锁缓存
【无锁的结果】
void AddCountByType(long type /, int count/){
//不加锁
Array[type]++; // 计数++
//Array[type] += count; // 计数增加count
}

如果这个缓存不加锁,当然可以达到最高的并发,但是多线程对缓存中同一块定长数据进行操作时,有可能出现不一致的数据块,这个方案为了提高性能,牺牲了一致性。在读取计数时,获取到了错误的数据,是不能接受的(作为缓存,允许cache miss,却不允许读脏数据)。

【脏数据是如何产生的】
这个并发写的脏数据是如何产生的呢,详见下图:

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

【数据完整性问题】
并发写入的数据分别是value1和value2,读出的数据是value-unexpected,数据的篡改,这本质上是一个数据完整性的问题。通常如何保证数据的完整性呢?
例子1:运维如何保证,从中控机分发到上线机上的二进制没有被篡改?
回答:md5

例子2:即时通讯系统中,如何保证接受方收到的消息,就是发送方发送的消息?
回答:发送方除了发送消息本身,还要发送消息的签名,接收方收到消息后要校验签名,以确保消息是完整的,未被篡改。
当当当当 => “签名”是一种常见的保证数据完整性的常见方案。

【加上签名之后的流程】


加上签名之后,不但缓存要写入定长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中的任意一个
4)数据读取的时候,不但要取出value,还要像消息接收方收到消息一样,校验一下签名,如果发现签名不一致,缓存则返回NULL,即cache miss

当然,对应到司机地理位置,与URL访问计数的case,除了内存缓存之前,肯定需要timer对缓存中的数据定期落盘,写入数据库,如果cache miss,可以从数据库中读取数据。

五、总结
在【超高并发】,【写多读少】,【定长value】的【业务缓存】场景下:
1)可以通过水平拆分来降低锁冲突
2)可以通过Map转Array的方式来最小化锁冲突,一条记录一个锁
3)可以把锁去掉,最大化并发,但带来的数据完整性的破坏
4)可以通过签名的方式保证数据的完整性,实现无锁缓存

评论

  1. 签名只是解决了原子性问题,没有解决可见性问题。写线程写入了,读线程不一定能读到。文中用的int16,主要是因为CPU保证原子写入吧,其实目前主流CPU能保证int32和int64原子写入的,但是不能保证的是跨线程可见性。比如没有volatile,一个int16就能够多线程++么,文中也是一样的,值和签名可能都是成功的,但是读线程来读时候,这这两个值可能都对其不可见。这时候产生脏读了。不过主存肯定会被刷新,所以看是否能够容忍,看过的主流实现,无锁java用的最多的还得加入CAS和volatile,golang很多用单goroutine操作读写。
  2. 还有就是对于addCountByType,内存直接++是不对的,加完直接刷主存,刷回去的数据可能是错的。因为读取到内存之后,写回主存之前,可能数据已经改变,刷回绝对值就让数据出错了
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,567评论 18 399
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,174评论 11 349
  • Java8张图 11、字符串不变性 12、equals()方法、hashCode()方法的区别 13、...
    Miley_MOJIE阅读 3,690评论 0 11
  • 关于今天去福利院的体验,我做了一些思考: 当你在陪伴别人的时候,何尝不是在陪伴你自己? 陪伴你的耐心,你的用心,你...
    吴聪WM阅读 223评论 0 2
  • 不在枝头 你在哪里 冬天已经离去 散落了的记忆 淹没一季的繁华 抚摸着斑驳的痕迹 还有一些关于光阴的故事 挂在枝头...
    美食美客阅读 143评论 0 0