Hero Image
全局变量加锁的优化

#golang #cache_line 开发中不可避免地会遇到需要对全局变量加锁的情况,而一旦并发量高了之后,加锁的变量有可能变成服务的性能瓶颈所在。所以千方百计地寻找优化方法。 改变锁的类型 如果业务中的全局变量是读多写少的应用场景,我们可以将互斥锁改为读写锁。即读取时对变量加读锁,这样可以支持多个线程并发读取同一个变量。而只有变量需要修改时才加写锁,保证写的时候不会被其他线程读取到错误的值。 互斥锁: func DoWork() { lock.Lock() defer lock.Unlock() // do something... } 读写锁: func Read() { lock.RLock() defer lock.RUnlock() // read global variable } func Write() { lock.Lock() defer lock.Unlock() // write global variable } 降低锁住的代码块长度 在Go中,我们常常会利用defer关键字的特性,写出如下的代码: func Write() { lock.Lock() defer lock.Unlock() // do something... } 但在实际代码中,如果对全局变量读写前后会有较长时间去做其他工作的情况下,就会造成极大的性能损耗。加锁之后没有立即对全局变量进行读写,或者对全局变量读写完之后没有立即释放锁,都会使其他线程没有办法立即抢到锁,从而拉低了整个系统的并发性能。 根据这个逻辑,可以将上述代码改成如下格式: func Write() { // do something... lock.Lock() // read or write global variable lock.Unlock() // do something.

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 前面几次着重讨论了在分布式系统中出现错误之后该如何处理。虽然长篇累牍谈了很多,但所谈到的情况还是过于乐观,现实则会更加复杂。我们可以根据墨菲定律做一个非常悲观的假定:即所有可能出错的事情最终一定会出错。 作为开发者,我们的核心任务是构建可靠的系统,即使系统面临各种出错的可能,也需要完成预定的工作(确保满足用户期望)。 所以,首先我们要充分认识目前所面临的挑战。 比如:故障可能来自网络问题,以及时钟与时序问题等等…… 在有一段工作经历中,我们的线上系统使用的是公有云,其中用到了不同地区的实例。两地区之间使用的是一条百兆带宽的专线。某个星期天的下午,领导通知我们有个服务出问题了,我查了程序日志之后,看到有许多访问上游服务网络超时的日志,即网络问题。随机运维查看之后告诉我们,上面提到的那条百兆专线被跑满了。至此,服务出问题的罪魁祸首已经找到——网络原因。当然,带宽被占满是由于业务增长还是某些服务出现bug抑或是恶意攻击,这就是另一个话题了。 所以,在我看来,所谓网络的不可靠性并不一定特指网络本身出了什么问题。 故障与部分失效 我们所开发的单点程序,通常会以一种确定的方式运行:要么工作,要么出错。单台节点上的软件通常不应该出现模棱两可的现象。而在分布式系统中,可能会出现系统的一部分正常工作,但其他部分出现难以预测的故障,我们称之为"部分失效"。 问题的难点就在于这种部分失效是不确定的:如果涉及多个节点和网络,几乎肯定会碰到有时网络正常,有时则莫名地失败。 正是由于这种不确定性和部分失效大大提高了分布式系统的复杂性。 不可靠的网络 我们目前关注的主要是分布式无共享系统,即通过网络连接多个节点。所以网络是跨节点通信的唯一途径,并且每台机器都有自己的内存和磁盘,一台机器不能直接访问另一台机器的内存或磁盘除非通过网络向对方发出请求。 诚然,无共享并不是构建集群系统的唯一方式,但却是当下构建互联网服务的主流方式。主要因为:硬件成本低廉,可以采用跨区域多数据中心来实现高可靠性,同时也可以给不同地域的用户提供更高的访问效率。 在我们的网络中一个节点发送数据到另一个节点,但是网络并不能保证他什么时候到达,甚至于,不能保证何时到达。 发送请求等待响应的过程中,有很多错误可能出现: 请求已经丢失(比如有人拔了网线,当然在系统上云之后这种物理层面的小问题,基本可以由底层的虚拟化系统来避免和保障) 请求可能在某个队列中等待,无法马上发送或响应(比如网络发送方或接收方已经超负荷,正如文章开头所提到的例子) 远程接收节点可能已经失效(比如依赖的上游服务崩溃,不过目前基于kubernetes的系统可以在一定程度上保障服务的稳定性) 远程节点可能暂时无法响应(比如正在运行长时间的垃圾回收。可以对服务进行内存的调优,可以在基于kubernetes的系统限制内存大小同时增加实例数等等) 远程接收节点已经完成了请求处理,但回复却在网络中丢失(比如交换机配置错误) 远程接收节点已经完成了请求处理,但回复却被延迟处理(比如网络或发送者的机器超出负荷) 处理类似的问题通常可以采用超市机制:在等待一段时间之后,如果仍然没有收到回复则选择放弃,并认为响应不会到达。 检测故障 许多系统都有自动检测节点失效这种的功能,比如 在ES中节点超过1分钟无响应则踢出集群,而后数据分片在正常的节点上进行重建。 在kubernetes中节点失效后,集群也会自动将失效节点的任务自动负载到其他节点之上。 超时与无限期的延迟 如果超时是故障检测唯一可行的方法,那么超时应该设置多长呢?很不幸,这并没有一个标准的答案。在上面提到的ES的例子是采用了一分钟的时间,延迟1分钟是为了防止偶尔的网络延迟等,因为将某个节点踢出集群后数据分片在集群中重新分配是需要消耗资源的。 设置较长超时则意味着更长时间的等待,才能宣告节点失效(在这期间,用户只能等待或者看到错误信息)。而较短的超时设置可以帮助更快地检测故障,但可能会出现误判,例如实际上节点只是出现短暂的性能波动(由于节点或者网络上的高负载峰值)。 参考链接 《数据密集型应用系统设计》

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 上一次我们主要从书中学习了主从架构消息同步相关的内容,而书中后面提到了多主节点复制(如多数据中心等)和无主节点复制(书中提到的Riak、Dynamo等并不了解,我认为最近比较火的一些区块链技术也是一些无主节点复制)。这两种模式在实际中(至少在我的认知范围内中小体量的公司基本不会维护一些多数据中心的场景)并不常见,这里不再过多讨论。 在一个单独的主从复制架构中,主节点和所有从节点都需要保存全量的数据。在项目初期,如果对未来的数据增量没有一个相对准确的判断,在业务发展一段时间之后应用就会遇到性能瓶颈,同时也有可能面临扩容困难等一系列问题。因此,分片的机制应运而生。 数据分区与数据复制 分区通常与复制相结合,即每一个分区的复制都是一个完整的主从架构的复制,而每个分区都会在多个节点上拥有相同的(不考虑微观上的延迟)副本,这意味着某条记录属于特定分区,而同样的内容会被保存到不同节点上以提高系统的容错性,这样即使某一个节点失效也不会影响整个集群的运行。 键-值数据的分区 面对海量的数据如何决定哪一条记录该放在哪个分区上呢?分区的主要目标就是将数据和查询负载均匀地分布在所有节点上。 而如果分区不均匀,就会出现某些分区节点比其他分区承担了更多的任务,即为数据倾斜。数据倾斜会导致分区效率严重下降以至于丧失了既定的目标。 避免热点最简单的办法是将数据随机分配到所有节点上。这种方法可以比较均匀地分布数据,但也有一个致命的缺点:如此写入到集群中的数据是无法通过特定key来读取的,因为没有办法知道数据保存在哪个节点上,所以不得不查询所有节点。 简单的改进方法可以通过key来分配分区,比如a-z的单词根据首字母分配到26个节点上。 基于关键字区间分区 假如上述根据单纯根据首字母字来分区时没有26个节点,那就需要将某些临近的字母放到同一个分区中,比如ab放到第一个分区,cd放到第二个分区……依次类推,26个字母需要13个节点即可放完。 但是基于关键字区间的分区也存在缺点,某些访问模式会导致热点。假如使用时间戳作为关键字,每一天的数据写入到一个分区中时,就会使这个分区成为热点。而其他分区始终处于空闲状态。 为了避免上述问题,可以在时间戳以外加入其他内容,比如数据类型等 基于关键字哈希值分区 对于上述数据倾斜与热点问题,许多分布式系统采用了基于关键字哈希函数的方式来分区。 一个好的哈希函数可以处理数据倾斜并使其均匀分布,这样从整体来看可以使数据均匀的分布到所有分区上。 负载倾斜与热点 如上所述,基于哈希的分区方法可以减轻热点,但依然无法完全避免。一个极端情况是所有读写都是针对同一个key进行的,则最终的请求都会被路由到同一个分区中。比如某个明星又离婚了等等… 而最让人困扰的是,数据倾斜的问题不光会出现在这些基础设施(指分布式存储,一些消息中间件等)中,也会出现在我们的应用层中。比如,为了防止数据乱序(有时候乱序的数据会给下游处理带来压力,比如Flink处理乱序数据产生的延迟问题。再者相同key发往不同分区时也会使Flink处理数据时产生大量的Shuffle带来的网络IO压力)从而采用哈希等方法将数据写入kafka的partition中。 即使采用了哈希分区的方法,如果出现某个热点key产生大量数据,就会造成数据倾斜。严重时将导致Kafka集群中某几个节点(主分片和所有副本所在的节点)磁盘被写满,进而导致整个集群不可用引发生产故障。 针对这个特特定的场景,由于同一key的数据可以在较长一段时间后忍受分区发生改变,因此可以在几个小时以后改变一次分区选择规则。诚然,这个办法并不能推广到所有数据倾斜问题的解决中。 分区与二级索引 上面讨论的分区方案都依赖于键值的数据模型(其实我个人认为,多数数据存储莫不如此,即便是回到MySQL也是通过主键查询,要么回表,再要么全表扫描)。键值模型相对简单,即都是通过关键字来访问记录。但是涉及到二级索引,情况就会变得复杂。 考虑到其复杂性,部分存储并不支持二级索引,如HBase作为一个面向列的存储,为了兼顾大数据量写入和OLAP场景的应用,并不支持二级索引。但是二级索引则是ES等一些全文搜索引擎的根本值所在。 而二级索引也是需要存储到不同分区中的,目前主要有两种方法来支持二级索引进行分区: 基于文档的分区 基于此条的分区 基于文档分区的二级索引 基于文档的分区是将所有二级索引在每个分区中都存了一个词条,而每个分区中的二级索引只记录自己分区的数据。 如果需要通过二级索引查询数据,就需要每一个分区的二级索引,再做统一处理。因此会导致读延迟显著放大。 基于此条的二级索引分区 基于词条的二级索引分区即与数据分区类似,二级索引的词条被放入所有分区,每个词条只存在于某一个分区(不考虑副本)。 这种方法对比前者,好处就是读取更为高效,不需要遍历所有分区的二级索引。相反这种方案写入性能相对较低,因为一个文档里面可能涉及到多个二级索引,而二级索引的分区又可能完全不同甚至不在同一节点上,由此势必引入显著的写放大。 而正因如此,实践中对全局二级索引的更新往往都是异步的。 参考链接 《数据密集型应用系统设计》

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 通常在生产中存储结构化数据最常用的是MySQL,而MySQL底层存储用的数据结构是B+树。当并发量达到一定程度之后通常会将单点的MySQL拆分成主从架构(在这之前可以加入内存型缓存如Redis等,属于不同层级的解决办法,不在此文讨论范畴)。 问题产生 在主从架构中主要问题之一有复制滞后。 这里以MySQL集群为例,主从复制要求所有写请求都经由主节点,而从节点只接收只读的查询请求(这一点在ES/Kafka的多副本分片中也有类似体现,主分片写入,从分片只支持读取)。对于读操作密集的负载(如web),这是一个不错的选择。 在这种扩展体系下,只需增加更多的从节点,就可以提高读请求的吞吐量。但是,这种方法在实际生产中只能用于异步复制,如果试图同步所有的从副本(即强一致性),则单个副本的写入失败将使数据在整个集群中写入失败。并且节点越多,发生故障的概率越高,所以以完全同步来设计系统在现实中反而非常不可靠。 在Kafka集群中为了提高消息吞吐量时与副本同步相关的设置通常会将acks设置为1或者0(1/0的区别在于leader是否落盘),partition的leader收到数据后即代表集群收到消息 说回到MySQL的主从集群,从上文中得到的结论,如果采用异步复制的话,很不幸如果一个应用正好从一个异步的从节点中读取数据,而该副本落后于主节点,这时应用读到的是过期的消息,表现在用户面前就会产生薛定谔的数据,即在同一时刻查询会出现两种截然不同的数据。 不过这个不一致的状态只是暂时的,经过一段时间之后,从节点的数据会更新到与主节点保持一致,即最终一致性。 解决办法 由于网络等原因导致的不一致性,不仅仅是存在于理论中,其是个实实在在的现实问题。下面分析复制滞后可能出现的问题,并找出相应的解决思路。 读自己的写 举个栗子: 当用户提交一些数据,然后刷新页面查看刚刚修改的内容时,例如用户信息,或者是对于一些帖子的评论等。提交新数据必须发送到主节点,但是当用户取数据时,数据可能来自从节点。 当集群是异步复制时就会出现问题,用户在数据写入到主节点而尚未达到从节点时刷新页面,看到的是数据修改之前的状态。这将给用户带来困惑。延伸到一些库存类型的应用,其实并不会导致超卖。如果用户看到是旧状态,误认为操作失败重新走了一遍流程,这时写入请求依然是访问到主节点,而主节点的数据是最新的,会返回失败。而这将进一步给用户带来困扰。 对于这种情况,我们需要"写后读一致性",该机制保证用户重新加载页面,总是能看到自己最新更新的数据。但对于其他用户看这条信息没有任何保证 方案一 总是从主节点读取用户可能会修改的信息,否则在从节点读取。即,从用户访问自己的信息时候从主节点读取,访问其他人的信息时候在从节点读取。 方案二 在客户端记住最近更新的时间戳,并附带在请求中。如果查到的数据不够新,则从其他副本中重新查询,或者直接从主节点中查询。 方案三 如果副本分布在多个数据中心(地理位置上的多个机房)等,就必须把请求路由到主节点所在的数据中心。至少目前还没有接触过这种项目,没有很深的理解,不过多讨论这种情况。 此外,依然存在一些其他问题需要考虑,如用户在多个设备上登录,这样一个设备就无法知道其他设备上进行了什么操作,如果采用方案二的话,依然会出现不一致。 单调读 在上述第二个例子中,出现了用户数据向后回滚的情况。 假设用户从不同副本进行了多次读取,用户刷新了一个网页,该请求可能会被随机路由到某一个从节点。用户2345先后在两个从节点上执行了两次完全相同的查询(先是少量滞后的从节点,然后是滞后很大的从节点),则很有可能出现以下情况。 第一个查询返回了最近用户1234所添加的评论,但第二个查询结果代表了更早时间点的状态。如果第一个查询没有返回任何内容,用户2345并不知道用户1234最近的评论,情况还好。但当用户2345看到了用户1234的评论之后,紧接着评论又消失了,就会感到十分困惑。 阿b(bilibili)的评论系统在使用中出现过类型的现象,但不清楚是否是由于审核等一些其他因素造成的。总之是在一个新视频发布后去刷新评论,第一次看到有人评论了,再次刷新评论又消失了。 单调读一致性可以确保不会发生这种异常。这是一个比强一致性弱,但比最终一致性强的保证。即保证用户依次进行多次读取,绝不会看到回滚的现象。 实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读操作(不同的用户当然可以从不同的副本读取)。例如,使用用户ID的哈希来决定去哪个副本读取消息,但如果该副本失效,系统必须要有能力将查询重新路由到其他有效的副本上。 前缀一致读 第三个由于复制滞后导致反常的例子。 比如A和B之间以下的对话: A: 请问B,你能听到吗? B: 你好A,我能听到 这两句话之间存在因果关系,即B听到了A的问题,然后再去回答。 现在如果有第三人在通过从节点上收听上述对话。假设B发的消息先同步了,观察者看到的对话就变成了这样: B: 你好A,我能听到 A: 请问B,你能听到吗? 这逻辑就变得混乱了。 防止这种异常需要引入另一种保证:前缀一致读。该保证是说,对于一系列按照某个顺序发生的写请求,那么读取这些内容时必须要按照当时写入的顺序 小结 上面讨论的是在保证最终一致性异步复制的情况下发生的。当系统决不能忍受这些问题时,那就必须采用强一致性,但随之而来的就是写入性能低下,故障率高,一个节点故障引发整个集群不可用等各种问题。都需要在应用开始进行得失的平衡。 再举个栗子: 在kafka这种对写入性能要求极高的应用中,如果发送的消息不是特别重要,有要求极高吞吐量的时候,比如日志收集等,则可以设置为Leader收到消息即代表成功 而在ES中,则必须要求数据分片的所有副本都写入成功才返回成功,采用了强一致性。而ES采用了健康检查,超过1分钟不活跃的节点就剔除集群等机制,从而保证了数据可以实时地写入。 延伸 结合到实际工作中的项目分析,也存在类似问题。 下面举两个类似的栗子: 例一 在某基础信息管理平台中需要一个模糊搜索的功能,各方面平衡之后采用在应用内存中使用前缀树的方式做缓存。由于应用是多实例的,这时数据的增删改就会在多实例之间存在一个短暂的不一致。 例二 在某数据处理应用中,由于每一条数据中需要有多个(一到十几不等)条目访问缓存。开始的时候将缓存放在Redis里,而应用访问Redis的时间大概需要十几到几十毫秒的时间,这样每一条数据的处理时间就在几十毫秒到几百毫秒之间。而使用多线程处理,则会造成消息的严重乱序。 测试下来,程序每秒只能处理不超过20条数据,大大影响了效率。而后将缓存改到内存中,省掉了访问Redis的时间,再结合Kafka的一些优化策略,极大的提高了应用吞吐量。测试后每秒大概可以处理几千条数据。缓存放到程序内存中之后,也同样会出现缓存不一致的问题。 下面是这两个应用中采用的一个缓存架构图: 在这个架构中,如果某个实例接收Redis消息慢了,就会出现不同实例间的数据不一致 参考链接 《数据密集型应用系统设计》

Hero Image
HTTP笔记

#http-code http协议(超文本传输协议) 是客户端和服务器端两者通信共同遵循的一些规则。主要内容是定义了客户端如何向服务器请求资源,服务器如何响应客户端请求。 请求中的POST与GET方法的区别 get是从服务器上获取数据,post是向服务器传送数据。 在客户端,Get方式在通过URL提交数据,数据在URL中可以看到;POST方式,数据放置在HTML HEADER内提交。 对于get方式,服务器端用Request.QueryString获取变量的值,对于post方式,服务器端用Request.Form获取提交的数据。 GET方式提交的数据最多只能有1024字节,而POST则没有此限制。 安全性问题。正如在(1)中提到,使用 GET 的时候,参数会显示在地址栏上,而 Post 不会。所以,如果这些数据是中文数据而且是非敏感数据,那么使用 GET;如果用户输入的数据不是中文字符而且包含敏感数据,那么还是使用 post为好。 HTTP 1.0 HTTP 1.0规定浏览器与服务器只保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器完成请求处理后立即断开TCP连接 当一个网页文件中包含了很多图像的地址的时候,那就需要很多次的HTTP请求和响应,每次请求和响应都需要一个单独的连接,每次连接只是传输一个文档和图像,上一次和下一次请求完全分离。即使图像文件都很小,但是客户端和服务器端每次建立和关闭连接却是一个相对比较费时的过程,并且会严重影响客户机和服务器的性能。当一个网页文件中包含JS文件,CSS文件等内容时,也会出现类似上述的情况。 HTTP 1.1 为了克服HTTP 1.0的这个缺陷,HTTP 1.1支持持久连接(HTTP/1.1的默认模式使用带流水线的持久连接),在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟。一个包含有许多图像的网页文件的多个请求和应答可以在一个连接中传输,但每个单独的网页文件的请求和应答仍然需要使用各自的连接。 HTTP 1.1还允许客户端不用等待上一次请求结果返回,就可以发出下一次请求,但服务器端必须按照接收到客户端请求的先后顺序依次回送响应结果,以保证客户端能够区分出每次请求的响应内容,这样也显著地减少了整个下载过程所需要的时间。 在HTTP 1.1,request和response头中都有可能出现一个connection的头,此header的含义是当client和server通信时对于长链接如何进行处理。 在HTTP 1.1中,client和server都是默认对方支持长链接的, 如果client使用HTTP 1.1协议,但又不希望使用长链接,则需要在header中指明connection的值为close;如果server方也不想支持长链接,则在response中也需要明确说明connection的值为close。不论request还是response的header中包含了值为close的connection,都表明当前正在使用的tcp链接在当天请求处理完毕后会被断掉。以后client再进行新的请求时就必须创建新的tcp链接了。 HTTP 1.1在继承了HTTP 1.0优点的基础上,也克服了HTTP 1.0的性能问题。 HTTP 1.1通过增加更多的请求头和响应头来改进和扩充HTTP 1.0的功能。如,HTTP 1.0不支持Host请求头字段,浏览器无法使用主机头名来明确表示要访问服务器上的哪个WEB站点,这样就无法使用WEB服务器在同一个IP地址和端口号上配置多个虚拟WEB站点。在HTTP 1.1中增加Host请求头字段后,WEB浏览器可以使用主机头名来明确表示要访问服务器上的哪个WEB站点,这才实现了在一台WEB服务器上可以在同一个IP地址和端口号上使用不同的主机名来创建多个虚拟WEB站点。HTTP 1.1的持续连接,也需要增加新的请求头来帮助实现,例如,Connection请求头的值为Keep-Alive时,客户端通知服务器返回本次请求结果后保持连接;Connection请求头的值为close时,客户端通知服务器返回本次请求结果后关闭连接。HTTP 1.1还提供了与身份认证、状态管理和Cache缓存等机制相关的请求头和响应头。HTTP 1.0不支持文件断点续传,RANGE:bytes是HTTP 1.1新增内容,HTTP 1.0每次传送文件都是从文件头开始,即0字节处开始。RANGE:bytes=XXXX表示要求服务器从文件XXXX字节处开始传送,这就是我们平时所说的断点续传。 HTTP 1.1和HTTP 1.0协议的区别 缓存处理 带宽优化及网络连接的使用 错误通知的管理 消息在网络中的发送 互联网地址的维护 安全性及完整性 HTTP 1.x和HTTP 2协议的区别 二进制分帧:HTTP 2采用二进制格式传输数据,而非HTTP 1.x的文本格式 头部压缩:头部表在HTTP 2的连接存续期内始终存在,由客户端和服务器共同渐进地更新。请求一发送了所有的头部字段,第二个请求则只需要发送差异数据,这样可以减少冗余数据,降低开销 多路复用:直白的说就是所有的请求都是通过一个TCP连接并发完成。HTTP 1.x虽然通过pipeline也能并发请求,但是多个请求之间的响应会被阻塞的,所以pipeline至今也没有被普及应用,而HTTP 2做到了真正的并发请求。同时,流还支持优先级和流量控制。 服务器推送:服务端能够更快的把资源推送给客户端。例如服务端可以主动把JS和CSS文件推送给客户端,而不需要客户端解析HTML再发送这些请求。当客户端需要的时候,它已经在客户端了。 HTTP 2主要是HTTP 1.

Hero Image
Redis缓存相关问题

#redis #cache #db 缓存一致性 形成原因 数据增删修操作造成的缓存内容与持久层内容的不一致 解决办法 先更新缓存后更新数据库:更新缓存后程序异常终止或持久化失败导致数据未持久化 先更新数据库后更新缓存:更新数据库后程序异常终止或更新缓存失败导致缓存数据与数据库不一致。解决办法:先更新缓存,后将数据修改操作写入持久化队列,比如Kafka,让下游服务执行持久化操作 缓存穿透 针对多个key 形成原因 缓存穿透是指查询一个不存在的数据,由于缓存是不命中时,去存储层(如MySQL)查找数据。如果从存储层查不到数据没有写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,导致缓存穿透。流量一旦大了之后,容易导致DB宕机,进而影响整个业务。利用不存在的key频繁攻击应用,这就是漏洞。 解决办法 使用布隆过滤器,如果从DB中没有查到则添加到布隆过滤器中。但由于布隆过滤器中存储的内容是不能修改的,需要注意使用场景。如果当前查询不存在的key以后会出现再实际数据中,使用布隆过滤器会导致改条数据无法查询到。 缓存不存在的key,如果从DB中没有查询到该数据,则将对应的key写入缓存中,并加一个合适的过期时间。value内容写一个不存在的标记,当程序读到该内容后,即知道这条key不存在对应的内容直接返回,不会将流量打入存储层。 缓存雪崩 多个key相同的过期时间 形成原因 缓存雪崩是指多个key在相同时间过期,导致缓存在某一时刻同时失效。请求全部转发到DB,DB瞬时压力过高宕机导致服务不可用。 解决办法 使用队列,将需要缓存的数据发往一个统一的队列中,依次写缓存。 随机过期时间,比如一个key需要缓存一小时,则在一小时的基础上随机±5分钟,这样可以一定程度上解决一批key集中过期的问题 缓存击穿 针对一个key 形成原因 某个设置了过期时间的key,在过期后某一时间有大量并发请求进来。而在第一个请求进来,从DB中查完还没来得及写入缓存中时后面的并发请求也进来了,就会造成同一个key并发访问DB,瞬间打垮存储层。 一般突然出现的热点key容易造成这种问题。 解决办法 使用分布式互斥锁,当一个key在缓存中没有查询到时,先去抢这个key的锁,抢到则去存储层进行查询,没有抢到则去缓存中查询,根据实际情况如果一次没有查找到可以循环查找几次(毕竟查数据库需要耗时)。 其他 数据的缓存策略,有时也需要根据实际业务来设定。比如一些热点key设置为永不过期,但永不过期也会给缓存的存储带来压力,而给key设置过期时间,又会带来以上几种问题。抑或是缓存设置永不过期,使用异步线程定期删除一些没有访问的key。 写代码的时候需要一个指导思想,但同时亦不可死搬教条。

Hero Image
OAuth 2.0扩展协议PKCE

#oauth PKCE全称是Proof Key for Code Exchange,在2015年发布,它是OAuth 2.0核心的一个扩展协议,所以可以和现有的授权模式结合使用,比如Authorization Code+PKCE, 这也是最佳实践,PKCE最初是为移动设备应用和本地应用创建的, 主要是为了减少公共客户端的授权码拦截攻击。 在最新的OAuth 2.1规范中,推荐所有客户端都使用PKCE,而不仅仅是公共客户端,并且移除了Implicit隐式和Password模式,那之前使用这两种模式的客户端怎么办? 是的,现在都可以尝试使用Authorization Code+PKCE的授权模式。那PKCE为什么有这种魔力呢? 实际上它的原理是客户端提供一个自创建的证明给授权服务器,授权服务器通过它来验证客户端,把访问令牌(access_token)颁发给真实的客户端而不是伪造的。 客户端类型 上面说到了PKCE主要是为了减少公共客户端的授权码拦截攻击,那就有必要介绍下两种客户端类型了。 OAuth 2.0核心规范定义了两种客户端类型, confidential 机密的, 和 public 公开的, 区分这两种类型的方法是, 判断这个客户端是否有能力维护自己的机密性凭据 client_secret。 confidential 对于一个普通的web站点来说,虽然用户可以访问到前端页面,但是数据都来自服务器的后端api服务,前端只是获取授权码code,通过code换取access_token这一步是在后端的api完成的,由于是内部的服务器,客户端有能力维护密码或者密钥信息,这种是机密的的客户端。 public 客户端本身没有能力保存密钥信息,比如桌面软件,手机App,单页面程序(SPA),因为这些应用是发布出去的,实际上也就没有安全可言,恶意攻击者可以通过反编译等手段查看到客户端的密钥,这种是公开的客户端。 在OAuth 2.0授权码模式(Authorization Code)中,客户端通过授权码code向授权服务器获取访问令牌(access_token)时,同时还需要在请求中携带客户端密钥(client_secret),授权服务器对其进行验证,保证access_token颁发给了合法的客户端,对于公开的客户端来说,本身就有密钥泄露的风险,所以就不能使用常规OAuth 2.0的授权码模式,于是就针对这种不能使用client_secret的场景,衍生出了Implicit隐式模式,这种模式从一开始就是不安全的。在经过一段时间之后,PKCE扩展协议推出,就是为了解决公开客户端的授权安全问题。 授权码拦截攻击 上面是OAuth 2.0授权码模式的完整流程,授权码拦截攻击就是图中的C步骤发生的,也就是授权服务器返回给客户端授权码的时候,这么多步骤中为什么C步骤是不安全的呢?在OAuth 2.0核心规范中,要求授权服务器的anthorize endpoint和token endpoint必须使用TLS(安全传输层协议)保护,但是授权服务器携带授权码code返回到客户端的回调地址时,有可能不受TLS的保护,恶意程序就可以在这个过程中拦截授权码code,拿到code之后,接下来就是通过code向授权服务器换取访问令牌access_token,对于机密的客户端来说,请求access_token时需要携带客户端的密钥client_secret,而密钥保存在后端服务器上,所以恶意程序通过拦截拿到授权码code也没有用,而对于公开的客户端(手机App,桌面应用)来说,本身没有能力保护client_secret,因为可以通过反编译等手段,拿到客户端client_secret,也就可以通过授权码code换取access_token,到这一步,恶意应用就可以拿着token请求资源服务器了。 state参数,在OAuth 2.0核心协议中,通过code换取token步骤中,推荐使用state参数,把请求和响应关联起来,可以防止跨站点请求伪造-CSRF攻击,但是state并不能防止上面的授权码拦截攻击,因为请求和响应并没有被伪造,而是响应的授权码被恶意程序拦截。 PKCE 协议流程 PKCE协议本身是对OAuth 2.0的扩展,它和之前的授权码流程大体上是一致的。区别在于,在向授权服务器的authorize endpoint请求时,需要额外的code_challenge和code_challenge_method参数,向token endpoint请求时,需要额外的code_verifier参数,最后授权服务器会对这三个参数进行对比验证,通过后颁发令牌。 原理分析 上面我们说了授权码拦截攻击,它是指在整个授权流程中,只需要拦截到从授权服务器回调给客户端的授权码code,就可以去授权服务器申请令牌了,因为客户端是公开的,就算有密钥client_secret也是形同虚设,恶意程序拿到访问令牌后,就可以光明正大的请求资源服务器了。 PKCE是怎么做的呢?既然固定的client_secret是不安全的,那就每次请求生成一个随机的密钥(code_verifier),第一次请求到授权服务器的authorize endpoint时,携带code_challenge和code_challenge_method,也就是code_verifier转换后的值和转换方法,然后授权服务器需要把这两个参数缓存起来,第二次请求到token endpoint时,携带生成的随机密钥的原始值(code_verifier),然后授权服务器使用下面的方法进行验证: plain code_challenge = code_verifier sha256 code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) 通过后才颁发令牌,那向授权服务器authorize endpoint和token endpoint发起的这两次请求,该如何关联起来呢?通过授权码code即可,所以就算恶意程序拦截到了授权码code,但是没有code_verifier,也是不能获取访问令牌的,当然PKCE也可以用在机密(confidential)的客户端,那就是client_secret+code_verifier双重密钥了。 参考连接 oauth文档

Hero Image
网络IO演进历程

网络IO模型演进历程 阻塞IO BIO(Blocking IO) 非阻塞IO NIO(Nonblocking IO) IO多路复用第一版 select/poll/epoll 异步IO AIO(Async IO) BIO 阻塞 IO,顾名思义当用户发生了系统调用后,如果数据未从网卡到达内核态,内核态数据未准备好,此时会一直阻塞。直到数据就绪,然后从内核态拷贝到用户态再返回。 BIO缺点,能支持的并发连接数比较少: 一台服务器能分配的线程数是有限的 大量线程频繁切换上下文会影响性能 核心矛盾:一个client分配一个线程是因为处理客户端读写是阻塞式的,为避免该阻塞影响接受后续新的client的连接,所以将阻塞逻辑交由单独的线程处理。 NIO 非阻塞 IO:见名知意,就是在第一阶段(网卡-内核态)数据未到达时不等待,然后直接返回。因此非阻塞 IO 需要不断的用户发起请求,轮询内核。 优点 将socket设为非阻塞后,在读取时如果数据未就绪就直接返回。可以通过一个线程管理多个client连接。 缺点 需要不断轮询内核,数据是否已经就绪,会造成很多无效的,太频繁的系统调用(system call)而造成资源浪费。 select/poll/epoll select 和 poll 的区别 select 能处理的最大连接,默认是 1024 个,可以通过修改配置来改变,但终究是有限个;而 poll 理论上可以支持无限个 select 和 poll 在管理海量的连接时,会频繁的从用户态拷贝到内核态,比较消耗资源 epoll对文件描述符的操作有两种模式: LT(level trigger)和 ET(edge trigger)。 LT模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。 ET模式:当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。 简言之:边沿触发仅触发一次,水平触发会一直触发。 epoll高效的本质在于: 减少了用户态和内核态的文件句柄拷贝 减少了对可读可写文件句柄的遍历 mmap 加速了内核与用户空间的信息传递,epoll是通过内核与用户mmap同一块内存,避免了无谓的内存拷贝 IO性能不会随着监听的文件描述的数量增长而下降 使用红黑树存储fd,以及对应的回调函数,其插入,查找,删除的性能不错,相比于hash,不必预先分配很多的空间 - select poll epoll 操作方式 遍历 遍历 回调 底层实现 数组 链表 哈希表 IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到rdllist里面。时间复杂度O(1) 最大连接数 1024(x86)或 2048(x64) 无上限 无上限 fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝 AIO 参考连接 Chapter 6.

Hero Image
修改Mac上brew安装的MySQL配置

#mysql #macos 一般MySQL 8.x安装完在select语句中使用group by时会报错,需要在my.cnf中配置设置sql_model参数。在Linux中,这个文件通常位于/etc目录下,而在Mac上,却不在这里。 在Mac本地安装的测试用的MySQL数据库,安装完成之后需要进行如下设置 设置sql_model 关闭ONLY_FULL_GROUP_BY模式 在sql命令行中查询sql_mode配置 select @@sql_mode; mysql> select @@sql_mode; +-----------------------------------------------------------------------------------------------------------------------+ | @@sql_mode | +-----------------------------------------------------------------------------------------------------------------------+ | ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION | +-----------------------------------------------------------------------------------------------------------------------+ 1 row in set (0.00 sec) 去掉第一项后得到: STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION 使用mysql --help命令获取my.cnf配置文件所在位置 # ... Default options are read from the following files in the given order: /etc/my.cnf /etc/mysql/my.cnf /opt/homebrew/etc/my.cnf ~/.my.cnf The following groups are read: mysql client # ... 我安装的MySQL在/opt/homebrew/etc/my.cnf目录下,添加一行: sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION 重启MySQL,问题解决 mysql.server restart 设置开机启动 cp /opt/homebrew/Cellar/mysql/8.0.27/homebrew.mxcl.mysql.plist ~/Library/LaunchAgents launchctl load -w ~/Library/LaunchAgents/homebrew.