本期主题为系统线程安全方面
part1 先看一个案例
我们的一个线上服务,一个更新用户礼物领取状态接口的逻辑是这样的:
简化这个更新接口的参数为 userId,giftId
1.传入用户Id和领取的礼物Id
2.先读取放在redis的用户map数据,领取状态是这个map的其中一部分
3.更新用户map数据里对应礼物的领取状态
4.整个map写回redis
这个逻辑有没有问题?
什么问题?为什么?
这个逻辑存在问题。
大家想一想,这是个数据更新的操作,2、3、4 操作需要原子语义
如果request1 过来了,userId,giftId=1
request1 在接口里执行到操作3
此时redis的状态,giftId=1,领取状态status=0
JVM的瞬时状态,giftId=1,领取状态status=1
这时request2 过来了,userId,giftId=2
request2 在接口里执行很顺利,直接完成操作了2,3,4
此时redis的状态,giftId=1,领取状态status=0
giftId=2,领取状态status=1
然后程序回到request1,继续执行,完成操作4
此时redis的状态,giftId=1,领取状态status=1
giftId=2,领取状态status=0
从流程图上看,更加的清晰明了
此时request2 请求产生的变更消失了
要怎么改造?
1.加锁,对方法或者方法块加锁
只在同一个实例里可行
2.多个实例呢?我们线上的服务往往会部署多个实例
所以,不管是单个实例里的多线程或多个实例的并发场景,都可能会存在线程安全问题
回到这个问题上,修改方案可以是这样
1.拆分数据逻辑,单独存储与读写。礼物的领取状态单独存储
2.去掉读取用户map数据的逻辑,多余。这个接口不关心用户的其他数据
3.不读取用户礼物的领取状态,直接update,比如使用redis的hash结构(设置失效)、数据库字段值 update 。。set status=1 where status=0
领取这个动作很简单,领取完就结束了,也不用考虑先读取状态判断这样的逻辑
4.单独读取这个领取状态的数据(从redis读取,无则走DB)
part2 线程安全
案例讲完了,我们来聊聊线程安全的概念
A piece of code is thread-safe if it only manipulates shared data structures in a manner that guarantees safe execution by multiple threads at the same time. by wikipedia
这是维基百科上的解释
维基百科上的解释,我觉得有4个词比较关键
shared data structures、multiple threads、manipulates、safe
概括下,出现线程不安全的条件
1.数据状态是共享的
2.多个线程会同时写
保证线程安全的方法
一种方式是通过避免共享数据状态来实现
1.线程栈的方式,当要有线程同时执行同一块代码时,通过栈来保存当时的数据状态,切换线程
2.thread local 。数据状态跟线程绑定,每个线程只访问自己的数据
3.不可变。数据对象本身不可变,一切都简单了
有时候数据共享是不可避免的,那只能通过锁的机制了
1.加锁,互斥操作
2.原子操作
回到刚才的例子
1.redis里的数据是共享的
2.虽然是面向用户自己,但还是存在多线程同时对共同数据进行写
对于这个例子,我觉得采用的修改方式应该属于原子操作
原来的数据结构是个大map,里面有所有的数据,redis不提供这样的分布式读写原子操作
我们拆分数据后,领取状态单独存放,redis update 操作是原子操作,update完的数据最新状态就是可见的了
最后
在分布式系统中,线程安全不局限于多线程本身,更加广义,是多服务器上的概念
这时就需要分布式锁或更合理的数据读写逻辑来保证线程安全
大家在进行设计开发时,需要分析好业务场景,是否会构成线程安全的问题
案例:
CRM-SDK里有一个定时任务,逻辑也比较简单
在程序里起了一个异步线程,定时执行更新逻辑,逻辑如下:
1.从数据库读出广告数据
2.在JVM里进行排序过滤
3.使用了一个redis List结构,直接update List,新增数据
部署了多个服务,每个实例里一个任务线程
问题依然是多线程同时 update 同一个List数据
修改:
1.改为独立任务,避免多线程
2.更新操作改为幂等操作,多次操作不影响结果,如直接覆盖redis的数据