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

> 原文地址 (https://mp.weixin.qq.com/s/BfuRWaB7RDjpGmQbZdmMZw)

有一类业务场景:

(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){

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]); // 解锁行锁

}

image

这个方案使得锁冲突降到了最低,但锁资源大增,在数据量非常大的情况下,内存往往是装不下的。

画外音:数据量比较小的时候,可以一个元素一把锁,典型的是连接池,每个连接用一把锁表示连接是否可用。

还没有方法进一步降低锁冲突,提升并发量呢?

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

无锁缓存,可能存在什么问题?

如果缓存不加锁,读写吞吐量可以达到极限,但是多线程对缓存中同一块定长数据进行写操作时,有可能出现不一致的脏数据。

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

读取时,获取到了错误的数据,是不能接受的。

画外音:作为缓存,允许 cache miss,却不允许读脏数据。

脏数据是如何产生的?

不加锁,在多线程并发写时,可能出现以下情况:

image

(1)线程 1 对缓存进行操作,对 key 想要写入 value1;

(2)线程 2 对缓存进行操作,对 key 想要写入 value2;

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

如何解决上述问题呢?

本质上,这是一个数据完整性问题。

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

通常如何保证数据的完整性呢?

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

md5。

又例如:即时通讯系统中,如何保证接受方收到的消息,就是发送方发送的消息?

发送方除了发送消息本身,还要发送消息的签名,接收方收到消息后要校验签名,以确保消息是完整的,未被篡改。

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

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

image

加上签名之后,不但缓存要写入定长 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)通过签名的方式保证数据的完整性,实现无锁缓存;

思路:写时写签名,读时校验签名。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容