Devise默认的unlock逻辑在数据量较大的情况下,unlock会出现慢查询的问题。

背景

数据库User表数据庞大,慢查询中经常会出现如下的慢查询语句:

SELECT  "users".* FROM "users" WHERE "users"."unlock_token" = 'xxx' ORDER BY "users"."id" ASC LIMIT 1;

调查发现,这是由Devise lockable中unlock的逻辑触发的。

解决方案

1. 添加unlock_token索引

出现慢查询,自然而然的会想到分析SQL、添加索引,此次遇到的问题SQL语句简单,仅有一个unlock_token的查询条件

但基于以下原因,添加索引不是一个好的选择

  • users表超大,添加索引时要注意耗时
  • 索引占用空间会增加,且unlock_token是一个临时值,每次lock都会更新
  • unlock_token存在为null的情况

2. 修改查询逻辑

是否能够通过ID和unlock_token联合查询,通过主键ID计划查询解决

Devise提供了覆盖controller等默认逻辑的方式,通过覆盖 Devise::UnlocksController#show解决unlock_token慢查询问题

1) 修改邮件内容

运行以下命令,生成devise views文件,其中包含unlock_instructions.html.erb的邮件模板

rails g devise:views

修改unlock_instructions.html.erb其中的unlock url,添加id参数 id: @resource.id,示例如下:

%p= link_to 'Unlock my account', unlock_url(@resource, id: @resource.id, unlock_token: @token)

2) 覆盖unlocks controller

运行一下命令,生成默认的覆盖controllers

rails g devise:controllers users

此命令会在app/controller/users目录下生成多个controller

confirmations controller
passwords controller
registrations controller
sessions controller
unlocks controller
omniauth_callbacks controller

修改其中的unlocks controller

class Users::UnlocksController < Devise::UnlocksController

  # GET /resource/unlock?id=xxx&unlock_token=abcdef
  def show
    self.resource = if resource_class.name == 'User' && params[:id].present?
                      unlock_token = Devise.token_generator.digest(self, :unlock_token, params[:unlock_token])

                      lockable = User.find_or_initialize_with_errors([:id, :unlock_token], { id: params[:id], unlock_token: unlock_token })
                      lockable.unlock_access! if lockable.persisted?
                      lockable.unlock_token = params[:unlock_token]
                      lockable
                    else
                      # 兼容部署上线前发送的unlock邮件
                      resource_class.unlock_access_by_token(params[:unlock_token])
                    end

    yield resource if block_given?

    if resource.errors.empty?
      set_flash_message :notice, :unlocked if is_flashing_format?
      respond_with_navigational(resource) { redirect_to after_unlock_path_for(resource) }
    else
      respond_with_navigational(resource.errors, status: :unprocessable_entity) { render :new }
    end
  end
end

值得提及的是

  • if条件下的执行语句

该语句是参考unlock_access_by_token源码修改的

unlock_access_by_token的查询逻辑是,仅根据unlock token进行数据查询,而我们需要改造成根据id和unlock token联合进行查询。 所以将其中的find_or_initialize_with_error_by(:unlock_token, unlock_token)替换为User.find_or_initialize_with_errors([:id, :unlock_token], { id: params[:id], unlock_token: unlock_token })

find_or_initialize_with_error_by方法根据一个属性查询一条数据,其内部还是调用find_or_initialize_with_errors方法。详情可点击find_or_initialize_with_error_by源码阅读

  • set_flash_message方法 devise 4.0.0.rc2新增并使用了DeviseController#set_flash_message!方法,由于老项目使用的是3.5.10版本,所以参考3.5.10的源码后,修改为使用set_flash_message方法

3) 修改routes

config/routes.rb中,增加如下内容

 Rails.application.routes.draw do
   devise_for :users, controllers: {
     unlocks: 'users/unlocks'
   }
end

至此,修改unlock的逻辑完成,unlock token慢查询的问题得到解决。

FYI: