对于数据存储层高并发问题,最先想到的可能就是读写分离,在网站访问量大并且读写不平均的情况下,将存储分为master,slave两台,所有的写都路由到master上,所有的读都路由到slave上,然后master和slave同步。如果一台salve不够,可以加多台,比如一台master,3台slave。对于什么是读写分离,以及读写分离有什么好处,这里不再叙述,有兴趣的可以参考
这里 。
在设计读写分离的时候,有几种解决方案:
1. 将读写分离放在dao层,在dao层, 所有的insert/update/delete都访问master库,所有的select 都访问salve库,这样对于业务层是透明的。
2. 将读写分离放在ORM层,比如mybatis可以通过mybatis plus拦截sql语句,所有的insert/update/delete都访问master库,所有的select 都访问salve库,这样对于dao层都是透明。
3. 放在代理层,比如MySQL-Proxy,这样针对整个应用程序都是透明的。
对于绝大多数情景,读写分离都适用,但是读写分离有一个问题是master slave同步,这个同步是会有一定延迟,因此有几个场景还是要注意的:
考虑下面一个用户注册场景。
boolean addUserSuccess = userDao.addUser(registUser); if (addUserSuccess) { cachedThreadPool.execute(() -> { EmailService.SendActivateEmail(userId); }); }
public static void SendActivateEmail(int userId) { User user = userDao.getUser(userId); String userName = user.getUserName(); String userEmail = user.getUserEmail(); String subject = "..."; String body = "..."; //调用邮件服务发邮件 }
如上,当新用户注册,在注册完成后,会发一封激活邮件,新增用户是insert,发邮件获取用户是select ,如果master slave存在延迟,有可能在这个时候获取不到用户。
对于分布式服务系统,都会有一些独立的子服务,比如用户服务,订单服务,这些服务通过http或者rpc访问, 如下是用户服务下的两个接口。
userservice.xx.com/user/userinfo post请求,用户新增或者更新用户
userservice.xx.com/user/userinfo/1 get请求,用于获取用户信息
这是两个接口服务,一个用于更新用户数据,一个用户获取数据,如果手机APP有一个操作是修改用户名,在调用更新用户接口修改用户名后,紧接着调用获取用户信息的接口,两个请求间隔短,而如果同步延迟,就有可能读取到脏数据。
对于上面第一种情况
1. 我们在可以在dao的方法中,再加一个参数,比如:
public User getUser(int userId, boolean isMaster)
由
业务层决定要在哪个库操作。
2. 根据具体的业务类型,将读写标志位放到线程上下文中。比如对于注册用户的操作,可以在开始处理的时候,在线程上下文中放入一个标志位master,在所有的dao 方法内,判断该标志位,如果是master,则从master读取。这样读写分离是由具体的
业务场景决定的。
对于上面第二种情况
1. 简单的方法,就是在请求参数中再加一个参数,服务端根据参数决定要在哪个库操作, 这样增加了前端的一些工作量。
2. 复杂一点的,可以在服务端处理,当修改了用户信息后,可以在redis或者memcache中新增一条Id记录,5秒过期,每次请求的时候,先到memcache中判断一下,对应的id是否存在,如果存在读master, 否则slave。只是这样无形之中增加了服务端的开销。
其实数据延迟没有那么严重,基本都是秒级的,对于上面第二个场景,可能两个请求来回,数据就已经同步好。不会出现脏读的情况,但是在一些特殊的场景下,比如网络抖动,新加字段,可能数据同步延迟会变大,此时master slave的数据会出现不一致,而如果业务上出现上面的两种情景,即insert/update/delete后立刻select,就有可能读不到或者脏读。所以具体把读写分离放在哪一层,还是要根据业务类型和实际情况来决定。