一次艰难的troubleshooting

Author Avatar
Sean Yu 5月 11, 2020
  • 在其它设备中阅读本文章

一次艰难的troubleshooting

起因

先说一下什么是ram-guards,这是我们的一个微服务的权限处理系统,接受一个oauth idtoken并认证,然后返回一个能代表权限的token。

起因是最近对ram-guards做升级,增加了切换application id的功能,使ram-guards可以通过ram系统为不同的app提供不同的权限服务(以前只服务于一个项目)。

本身这只是个微小的改动,加一个参数就好,但是在spring security的设计体系中,下面的这个简单的接口承担着装配用户以及权限的核心

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

但这个接口有个很大的缺点,就是只有一个string做参数,为了满足我们多个参数的需求,又不想丑陋的在string里split,我决定把这个接口用我自定义的接口代替。简单来说就是写一个能接收自定义object的接口,并为这个自定义接口提供一个AuthenticationProvider,注册到spring security的AuthenticationManager中,同时重写DefaultTokenServices中refreshtoken所用的PreAuthenticatedAuthenticationProvider,并将tokenservice和AuthenticationManager注册到AuthorizationServerEndpointsConfigurer中。

因为这部分改动不是本文的重点,在这里不展开说了。

从代码角度来说我对这部分改动还是很自信的,因为自己经过多轮测试并且其他team也一直在使用这套代码进行权限获取,但就是这些很自信的改动,在dev环境上跑出了问题。

troubleshooting

新版本的ram-guards上了dev环境之后,测试在dev上跑了bdd,结果大批量的fail掉了,报的错误是前端没有拿到用户的权限。但是奇怪的是,手工测试却并没有出现问题,而且老版本的ram-guards bdd也从来没有fail过。因为对自己的盲目自信以及function测试并没有出现问题,我最先把怀疑指向了bdd的代码会不会有问题,但经过一番查验,并没有发现bdd的代码有问题。

此时我便陷入了僵局,一般情况下,系统有问题通常都会要求有稳定的复现,但这是一个复现不了,只会在bdd中才发生的问题。在不断的查验中,终于意识到bdd场景是并发进行的,十多个bdd脚本在一秒内并发,这是手工测试所不能复现的场景,会不会是新版本代码在并发场景下会出问题?带着这种怀疑,我决定做一次压测。

压测工具为ab,进行API压测,以单秒并发100次请求为标准进行压测,果然,dev上的新版本出现了大批量的API fail,test上的老版本则全部通过。

既然是压测出的问题,首先想到的肯定是不同环境上的资源分配是不是影响因素,于是便在dev上也部署了一份老版本ram-guards的代码到同一个node上,在进行压测,发现并没有问题。

那这样来看就不是环境资源配置的问题了,那代码会哪里出问题呢,功能都OK,但一上并发就挂。我便仔细分析了下log,发现压测中,最先挂的是数据库连接池,总会报出数据库连接失败的错误。先说明下,ram-guards本身是不需要数据库的支持的,这里的数据库只是用来记录audit log用的。我们处于成本控制,使用了最大连结数只能有100个的cloud db,而每个service都有记录audit log的需求,所以我限制了每个service在log db上只能开2个active连接的连接池。那么会不会是连接池数量太少引起的性能问题呢?

如果仅仅是连接池数量太少导致的,那么老版本的代码在并发场景下应该也会出问题,虽然增加连接数也可能解决问题,但这并不像是rootcause。那会不会是代码优化后执行速度过快,导致单秒写入数据库的请求增多,进而把连接池压垮?亦或者是代码重构后,写入数据库的次数和内容增多,导致单个commit处理时间过长,把数据库连接池压垮?翻来覆去没想出问题所在,我决定开debug log看一下,因为全局的debug log太多,反而不好定位问题,这里我只开启跟log db有关的debug:

logging:
  level:
    ROOT: info
    com.ibm.ram.guards: debug
    org.springframework.orm.jpa: debug
    org.hibernate: debug
    org.postgresql: debug
    com.zaxxer.hikari: debug

在开启debug后,仔细对着了log的不同,发现了蹊跷。

这是老版本代码在并发场景下的log:

2020-06-16 07:13:49.971 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = false
2020-06-16 07:13:49.982 DEBUG 1 --- [nio-8080-exec-5] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Reset (autoCommit) on connection org.postgresql.jdbc.PgConnection@100d71db
2020-06-16 07:13:49.971  INFO 1 --- [nio-8080-exec-5] g.a.o.c.RamGuardsJweAccessTokenConverter : CP01-log user: ...
2020-06-16 07:13:49.981 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = true

这是新版本代码在并发场景下的log:

2020-06-16 07:13:31.550 DEBUG 1 --- [l-1 housekeeper] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Pool stats (total=2, active=0, idle=2, waiting=0)
2020-06-16 07:13:49.886 DEBUG 1 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [org.springframework.security.oauth2.provider.token.DefaultTokenServices.createAccessToken]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2020-06-16 07:13:49.887 DEBUG 1 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(256287368<open>)] for JPA transaction
2020-06-16 07:13:49.887 DEBUG 1 --- [nio-8080-exec-5] o.h.e.t.internal.TransactionImpl         : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
2020-06-16 07:13:49.887 DEBUG 1 --- [nio-8080-exec-5] o.h.e.t.internal.TransactionImpl         : begin
2020-06-16 07:13:49.891 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = false
2020-06-16 07:13:49.891 DEBUG 1 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1ee8f5f6]
2020-06-16 07:13:49.892  INFO 1 --- [nio-8080-exec-5] c.i.d.s.authorization.aop.AuditLogAop    : CP01-log user: ...
2020-06-16 07:13:49.913 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = false
2020-06-16 07:13:49.934 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = true
2020-06-16 07:13:49.934 DEBUG 1 --- [nio-8080-exec-5] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Reset (autoCommit) on connection org.postgresql.jdbc.PgConnection@100d71db
2020-06-16 07:13:49.908  INFO 1 --- [nio-8080-exec-5] g.a.o.c.RamGuardsJweAccessTokenConverter : user: ...
2020-06-16 07:13:49.935 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = false
2020-06-16 07:13:49.955 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = true
2020-06-16 07:13:49.955 DEBUG 1 --- [nio-8080-exec-5] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Reset (autoCommit) on connection org.postgresql.jdbc.PgConnection@100d71db
2020-06-16 07:13:49.935  INFO 1 --- [nio-8080-exec-5] g.a.o.c.RamGuardsJweAccessTokenConverter : CP01-log user: ...
2020-06-16 07:13:49.958 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = false
2020-06-16 07:13:49.969 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = true
2020-06-16 07:13:49.969 DEBUG 1 --- [nio-8080-exec-5] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Reset (autoCommit) on connection org.postgresql.jdbc.PgConnection@100d71db
2020-06-16 07:13:49.958  INFO 1 --- [nio-8080-exec-5] g.a.o.c.RamGuardsJweAccessTokenConverter : user: ...
2020-06-16 07:13:49.971 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = false
2020-06-16 07:13:49.981 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = true
2020-06-16 07:13:49.982 DEBUG 1 --- [nio-8080-exec-5] com.zaxxer.hikari.pool.PoolBase          : HikariPool-1 - Reset (autoCommit) on connection org.postgresql.jdbc.PgConnection@100d71db
2020-06-16 07:13:49.971  INFO 1 --- [nio-8080-exec-5] g.a.o.c.RamGuardsJweAccessTokenConverter : CP01-log user: ...
2020-06-16 07:13:49.984 DEBUG 1 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
2020-06-16 07:13:49.984 DEBUG 1 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(256287368<open>)]
2020-06-16 07:13:49.984 DEBUG 1 --- [nio-8080-exec-5] o.h.e.t.internal.TransactionImpl         : committing
2020-06-16 07:13:49.984 DEBUG 1 --- [nio-8080-exec-5] org.postgresql.jdbc.PgConnection         :   setAutoCommit = true
2020-06-16 07:13:49.985 DEBUG 1 --- [nio-8080-exec-5] o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(256287368<open>)] after transaction

可以明显的看到,在代码更新后,每次向数据库写log的时候都加上了声明式事务,而在这个事务中我们一共写了5次log,才将数据库连接释放。而在并发场景下,后进来的线程也会开启事务,但是数据库连接池却没有可有连接给支撑,在timeout时间过了之后,就会报大批量连接不上数据库的错误。这是rootcause,那为什么莫名其妙会加上事务呢?

经过仔细研读spring security代码,发现一个以前没有注意到的事情,就是DefaultTokenService重的createAccessToken()方法上有@Transactional注解,而我们写log的代码又正好是在这个方法内执行的:

public class DefaultTokenServices implements AuthorizationServerTokenServices, ResourceServerTokenServices,
        ConsumerTokenServices, InitializingBean {

    @Transactional
    public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {

        OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
        OAuth2RefreshToken refreshToken = null;
        if (existingAccessToken != null) {
            if (existingAccessToken.isExpired()) {
                if (existingAccessToken.getRefreshToken() != null) {
                    refreshToken = existingAccessToken.getRefreshToken();
                    // The token store could remove the refresh token when the
                    // access token is removed, but we want to
                    // be sure...
                    tokenStore.removeRefreshToken(refreshToken);
                }
                tokenStore.removeAccessToken(existingAccessToken);
            }
            else {
                // Re-store the access token in case the authentication has changed
                tokenStore.storeAccessToken(existingAccessToken, authentication);
                return existingAccessToken;
            }
        }

        // Only create a new refresh token if there wasn't an existing one
        // associated with an expired access token.
        // Clients might be holding existing refresh tokens, so we re-use it in
        // the case that the old access token
        // expired.
        if (refreshToken == null) {
            refreshToken = createRefreshToken(authentication);
        }
        // But the refresh token itself might need to be re-issued if it has
        // expired.
        else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
            ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
            if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
                refreshToken = createRefreshToken(authentication);
            }
        }

        OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
        tokenStore.storeAccessToken(accessToken, authentication);
        // In case it was modified
        refreshToken = accessToken.getRefreshToken();
        if (refreshToken != null) {
            tokenStore.storeRefreshToken(refreshToken, authentication);
        }
        return accessToken;

    }
}

为什么createAccessToken方法要加事务呢?因为在许多场景下,accesstoken是有持久化的需求的。因为ram-guards用的是jwt(jwe)格式的,它能够自解析,所以我们不需要持久化。但如果是一个普通的token,就需要使用JdbcTokenStore来存库了。

那么现在有三个问题:

  1. 为什么我们的代码里开启了事务(还是log-db上的事务)

spring boot在引入任何db的datasource时,会自动通过HibernateJpaAutoConfiguration->HibernateJpaConfiguration->JpaBaseConfiguration.transactionManager()来建立一个transactionManager的bean,并通过TransactionAutoConfiguration去进行自动配置。transactionManager会扫描所有bean中有@Transactional注解的方法,并以aop的方式去开启事务。

  1. 为什么以前的代码没有使用事务,现在的代码却使用了

这就得直接上代码了。

老版本的代码:

    @Bean
    @Primary
    public DefaultTokenServices tokenServices() throws Exception {
        final DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        return defaultTokenServices;
    }

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        final TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Collections.singletonList(accessTokenConverter()));
        endpoints
                .requestValidator(requestValidator())
                .tokenStore(tokenStore())
                .tokenEnhancer(tokenEnhancerChain)
                .authenticationManager(authenticationManagerBean)
                .userDetailsService(refreshUserDetailsService);
    }

新版本的代码:

    @Bean
    @Primary
    public DefaultTokenServices tokenServices() throws Exception {
        final DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
        provider.setPreAuthenticatedUserDetailsService(token -> {
            UsernamePasswordAuthenticationToken authenticationToken = (UsernamePasswordAuthenticationToken) token.getPrincipal();
            RamGuardsUser ramGuardsUser = new RamGuardsUser();
            ramGuardsUser.setUsername(authenticationToken.getPrincipal().toString());
            Map<String, Object> details = (Map<String, Object>) authenticationToken.getDetails();
            if (details!=null){
                ramGuardsUser.setIsSystemPartner(Boolean.parseBoolean(details.get("system_partner").toString()));
            }
            ramGuardsUser.setDetails(details);
            ramGuardsUser.setCredentials(authenticationToken.getCredentials());
            return refreshUserDetailsService.loadUserByRamGuardsUser(ramGuardsUser);
        });
        defaultTokenServices.setAuthenticationManager(new ProviderManager(Collections.singletonList(provider)));
        defaultTokenServices.setTokenEnhancer(accessTokenConverter());
        defaultTokenServices.setTokenStore(tokenStore());
        return defaultTokenServices;
    }

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .tokenServices(tokenServices())
                .requestValidator(requestValidator())
                .authenticationManager(authenticationManagerBean);
    }

打眼一看好像没什么不同,但仔细看一下就会发现,老版本代码中,DefaultTokenServices虽然注册成为了bean,但却并没有把这个bean提供给AuthorizationServerEndpointsConfigurer,而当AuthorizationServerEndpointsConfigurer中的tokenservice没有值的时候,事实上会调用createDefaultTokenServices方法去创建一个不是bean的DefaultTokenServices实例,代码如下:

   public AuthorizationServerTokenServices getDefaultAuthorizationServerTokenServices() {
        if (defaultTokenServices != null) {
            return defaultTokenServices;
        }
        this.defaultTokenServices = createDefaultTokenServices();
        return this.defaultTokenServices;
    }

    private DefaultTokenServices createDefaultTokenServices() {
        DefaultTokenServices tokenServices = new DefaultTokenServices();
        tokenServices.setTokenStore(tokenStore());
        tokenServices.setSupportRefreshToken(true);
        tokenServices.setReuseRefreshToken(reuseRefreshToken);
        tokenServices.setClientDetailsService(clientDetailsService());
        tokenServices.setTokenEnhancer(tokenEnhancer());
        addUserDetailsService(tokenServices, this.userDetailsService);
        return tokenServices;
    }

而我们代码中所使用的tokenservice,恰恰就是AuthorizationServerEndpointsConfigurer中的tokenservice。

所以说,在老版本代码中,我们使用的tokenservice并不是spring的一个bean,所以事务也就不会开启,而新版本代码中使用的是一个bean,所以也就启用了事务。

  1. 我们既然不需要事务,如何把这个事务去掉

找到了rootcause之后,解决就变得简单了许多,摆在我面前有两个方案:1、将TransactionAutoConfiguration给exclude出去,不让spring boot自动装配。2、不把DefaultTokenServices注册为bean。

最后选择了方案2,因为我认为既然ram-guards没有被持久化的需求,就不应该被任何transactionManager扫描到。同时,我也把记录auditlog的方法从createAccessToken方法中拿了出来,在TokenEndpoint中以aop的方式切入记log,这样也减少了数据库的写入次数。

至此,这个issue算是解决了。

后记

说一下我们记audit log的方式,直接通过db log appender去写入数据库事实上是一个开销很大的方式,尤其是在我们log db可用连接资源还十分有限的情况下。这个我们暂时讨论,会在后期优化中用kafka,批量记入到kafka中,在批量写入db进行持久化,减小db的开销。

以后要多做一些API压测(ab或者jmeter),来保证接口在一个标准下不挂,可以以单实例qps为多少来制定这个标准,来保证我们接口的稳定性。

个人认为很有价值的一次troubleshooting,后记中提出的问题都值得我们进行实践。