使用ThreadLocal实现的读写分离在迁移后的偶发错误
最近莫名的会有错误日志,说有写操作因为走了读库而报了read only的异常,由于并没有造成应用使用的问题,开始我以为哪的配置错误就没当回事让程序员自己去查了,然而。。。
背景:之前的博客里提到过,读写分离功能是直接从老系统迁移过来的,老系统可能是因为主从库之间同步延迟而使用了基于请求层面的读写分离,迁过来的时候降成了基于Service,新系统用了dubbox,这些Service直接充当Provider。读写分离的实现是重写了了DataSource,请求到Service的时候会先经过拦截器判断方法是走读库还是写库,默认是写库,如果是要走读库的会用ThreadLocal标记为读,否则标记为写。由于读压力远高于写,所以并不是所有读都走写库,避免两个数据库压力不均,通过人为指定来平衡两个库的压力,也就是说是以标记为准而不是以请求类型为准。
从第一截图可以看到,当遇到没有被标记的事务获取数据源的时候,默认会设置为写。原本这其实是没有什么问题的,因为需要走到写库的都会先被拦截器拦截标记为写:
而这个determineCurrentLookupKey就是我们重写的方法,也就是上面的第一个截图,所以貌似看上去没什么问题。因为一个事务被设置一个数据源,即使请求因为线程池被复用的关系,上一次请求的ThreadLocal没有专门清理,在新的请求进来的时候这个值也会被拦截器更新。而且,嵌套事务中每个子事务都会保存一个自己的状态,子事务执行完回将状态重置回它开始时候得状态(这个状态也是通过ThreadLocal保存的):
数据源与连接的绑定关系也是通过ThreadLocal保存的,因为一个事务也只能使用一个连接,所以将唯一的数据源(即使可能不确定)与连接绑定也没什么关系,反正事务结束后会清理掉。
本来如果维持这个事务体系的闭环就好了,然而依然是因为determineCurrentLookupKey被重写的原因,begin时候数据源的状态对Spring的事务体系就不可见了,是运行时动态选择的,而这个状态的选择在一般情况下依赖于拦截器。为什么说是一般情况下呢,因为有特殊情况会出现,比如第一个图中的=null,只要拦截器先执行了就不会出现=null的情况,问题就出现在先执行上。如果TransactionInterceptor先执行,这里就没有依赖于拦截器,刚才一直都在重点说ThreadLocal,如果这个线程刚巧在上一个事务中把第二个图中的holder设为了读,一连串的问题就来了,首先外层事务的状态就不对了,又因为各个嵌套的子事务的的状态都是隔离的,即使后续子事务的启动是在外层方法被拦截之后得到了正确的连接,但是由于一个事务只有有一个连接,ThreadLocal中已经保存的有数据源和连接了,所以每个事务的状态可能是对的,但提交时用的也就是上图中的连接时错的,本来应该是指向写库的就变成读库了,提交就是用的读库的连接进行的。
找到问题,解决就容易了,只要设置一下拦截器的执行顺序就好了;也可以通过在提交事务后清理我们自己的ThreadLocal来解决,只需要继承DataSourceTransactionManager重写其中比如清理方法就可以了。
==========================================================
咱最近用的github:https://github.com/saaavsaaa
微信公众号: