在组内做的一个简单的分享,又刚好整理了下之前的两篇blog重新post,就叫并发糗事吧。
并发下的变量,这个大概是在处理并发问题时最先遇到的问题吧。在用PHP写些简单的web应用时,Nginx作为http server,将每一个到来http request交给一个单独的PHP进程来处理,所以较少涉及共享变量的场景,而在后端server 里,例如thrift server,为了节省资源,我们会把一些不常改变的实例声明为全局变量,go server中我们会声明 若干sql.DB作为全局共享的实例,在server的各个部分通过这些全局的DB实例来访问不同的数据库,当然这一切的前提是:
DB is a database handle representing a pool of zero or more underlying connections. It's safe for concurrent use by multiple goroutines.
在并发场景下使用全局变量时,最先关注的一定是这个变量是不是并发安全的,在这个点上遇到过两个case:
后端thrift server之间进行数据通信的时也需要创建thrift client,在代码实现时很自然的想到的全局变量,在server 启动时创建了client,后续在各个协程里使用同一个实例,好在模块的QPS较低,没有触发异常,这里其实是共享了同一个 socket实例,go的net.Conn本身是并发安全的,所以在read/write的时候不会有异常抛出,不过到了client层由于当前协 程拿到的可能发送给其他协程数据,这时就会有
out of sequence response
抛出来了。
这里要先提到一个基础模块:dm303。来自于facebook的fb303,用于将业务server的状态暴露给外部,使我们能够实时的 观察业务server的状态。简单来说就是维护一个全局map,业务server对map进行操作,外部通过dm303服务访问到map中的 数据从而了解server的状态。
好了回到正题,最初在实现golang版本的dm303时,这个全局map忘记加锁,而map本身并不是thread-safe,业务server 对map进行并发操作时产生race condition,导致最终数据出现异常,后面发现这个问题之后,对map的操作增加了一层封 装,将操作信息存入chan,再有单独的协程负责从chan中取出数据进行操作,从而避免了race condition的问题。
我们后端数据流处理的很多模块使用了生产者消费者模式,例如某个数据的回调模块,用py实现时,生产线程负责从Mysql 取待处理的数据放入Queue并Sleep固定时间,而消费线程负责从Queue中获取数据完成回调并将Mysql中该记录的状态标记为 处理成功。
按照上述逻辑实现,当生产线程的生产速度大于消费线程的消费速度时,由于生产线程上一次获取部分的数据的状态可能还 没有标记为处理成功,这些数据会再一次被取出,Queue本身没有去重的功能,那么这些数据就会被重复发送。
好吧,现在我们升级为第二个版本,生产线程取出数据后,先将数据状态标记为一个中间状态再放入Queue,这样在程序正常 运行时就不会出现重复生产的现象了。但是这样带来的问题是,如果我们关闭了服务,会有一些中间状态的数据还没有来得 及处理,这样程序下次启动时就需要先从Mysql中取出上一次退出时状态还是处理中的数据,而且也增加了数据库操作,反而 变得复杂了。
索性我们不要并行了,使用Queue.join()来阻塞生产线程,这样在Queue中的数据被处理完之前,生产线程会一直阻塞住。 Queue中的数据状态没有修改状态,这样程序重启时也不用考虑过多逻辑,问题变得简单了许多。
其实数据重复的问题是不可避免的,因为数据的处理过程不具备原子性,程序异常挂掉的话可能出现的问题就复杂了,所以 类似的逻辑里,数据接收方进行数据去重是必不可少的,当然我们要做的是如何能让程序在正常状态下不去发送重复数据。
Mysql支持并发的能力很强大,产品线上一个用户数据记录的表已经保存了5千万条记录,仍然能够很好的提供数据查询速度, 当然前提是建立正确的索引,关于索引推荐美团的这篇文章: MySQL索引原理及慢查询优化 。
问题源自于一条产品线上的一个类似抽奖红包的逻辑,每次用户中奖之后,需要做一个中奖金额total自增的操作,这样来限 制媒体能发出去的红包的总金额,而这个total值被存储在mysql的一条记录中,每次有用户中奖,就需要更新这条记录,这样 随着用户量的增加,很快就出现问题了。当我们开启是一个事务来修改这条记录时,其他同样的事务就会被阻塞,这样使得对 这条记录的并发更新变的非常慢,导致mysql链接一直被占用不能释放,最后mysql的链接被占满,使得数据库无法连接。
类似的逻辑应该可以在内存中进行,例如上文提到的利用全局map来实现,即便是加了锁,相信对于业务server来说要达到更新 map的性能瓶颈也不大可能。
用户消费积分的场景,需要判断用户积分是否充足并减去消费的金额数,而在并发场景下,如果用户两次消费请求同时到来,可 能会产生race condition,最终可能用户积分被消费成了负数。
解决方法一种是在业务逻辑上将每个消费请求先入队列,再由单独的线程逐个处理这些请求,从而转化为了串行处理过程。 类似上文提到的map的封装的原理。
另一个方法是利用mysql的行锁来解决,innodb提供了 select … for update 语句来锁住某一行,在事务中执行 该语句之后,在该事务提交之前,其他相同的select … for update会被阻塞直到当前事务提交,而正常的select语句可以 执行,这样利用数据库特性将并行变成了串行,避免了race condition带来的问题。
并发场景下很多问题会变得复杂些,其实在很多对性能要求不高的场景下,用串行逻辑来解决问题,可能更加简单也不容易 出问题,而在一些高并发的场景下,就需要考虑更多的内容。
Til next time
at 22:36