2023年4月

一次排查某某云上的redis读超时经历

性能排查,服务监控方面的知识往往涉及量广且比较零散,如何较为系统化的分析和解决问题,建立其对性能排查,性能优化的思路,我将在
这个系列
里给出我的答案。

问题背景

最近一两天线上老是偶现的redis读超时报警,并且是
业务低峰期间
,甚是不解,于是开始着手排查。

以下是我的排查思路。

排查思路

查阅 redis 慢查询日志

既然是redis超时,首先想到的还是 对于redis的操作命令存在慢查询导致的。
image.png
redis的慢查询阈值是10ms,唯一的慢查询是备份时的bgrewriteaof语句,并不是业务命令,既然从慢查询很日志看不出端倪,那就从redis服务器本身查找问题,所以我又去看了redis服务机器的各项硬件指标。

检查 报警期间 redis 各项负载指标

看了下各项监控指标,cpu,内存,qps等等,毫无意外的正常。(这里就不放图了,应为太正常了,分析意义不大)

既然服务端看不出毛病,那是不是客户端的问题,于是我又去检查了我们ecs服务器这边机器的情况。

检查报警期间 ecs服务器的各项指标数据

cpu,内存,带宽等等也是正常且处于较低水平。

排查到这里,重新思考慢查询日志究竟是什么?

慢查询记录的真的是redis命令执行的所有时间吗?redis命令完整的执行过程究竟是怎样的?
慢查询日志仅仅是记录了命令的执行时间,而整个redis命令的生命周期是这样。

客户端命令发送->redis服务器接收到命令,放入队列排队执行->命令执行->返回给客户端结果

那么有没有办法监控到redis的延迟呢?如何才能知道redis的命令慢不是因为执行慢,而是这个过程当中的其他流程慢导致的呢?

redis 提供了监控延迟的工具
开启延迟,设置延迟阀值

CONFIG SET latency-monitor-threshold 100

查阅延迟原因

latency latest

但是这个工具真正实践起来的效果并不能让我满意,因为似乎他并不能把网络因素考虑其中,实践起来的效果它应该是只能将命令执行过程中可能导致延迟的原因分析出来,但是执行前以及执行后的命令生命周期的阶段并没有考虑进去。

当我开启这个工具监控线上redis情况,当又有读超时出现时,latency latest 并没有返回任何延迟异常。

再思考究竟读超时是个什么问题?

客户端发出去了命令,然后阻塞等待redis服务端读的结果,如果没有结果返回,就会触发读超时发生。在go里面代码是如何实现的。

我们用的redis客户端是go-redis

func (c *baseClient) pipelineProcessCmds(cn *pool.Conn, cmds []Cmder) (bool, error) {
	err := cn.WithWriter(c.opt.WriteTimeout, func(wr *proto.Writer) error {
		return writeCmd(wr, cmds...)
	})
	if err != nil {
		setCmdsErr(cmds, err)
		return true, err
	}
    // 看到将读取操作用WithReader装饰了一下
	err = cn.WithReader(c.opt.ReadTimeout, func(rd *proto.Reader) error {
		return pipelineReadCmds(rd, cmds)
	})
	return true, err
}

读取redis服务端响应的方法 是用cn.WithReader进行了装饰。

// 读取之前设置了超时时间
func (cn *Conn) WithReader(timeout time.Duration, fn func(rd *proto.Reader) error) error {
	_ = cn.setReadTimeout(timeout)
	return fn(cn.rd)
}

而cn.WithReader 里,首先便是设置此次读取的超时时间。如果在规定的超时时间内,需要读取的结果没有全部返回也会导致读超时的发生,
那么会不会是由于返回结果过多导致读取耗时验证呢?

具体的分析了下报警出错的命令,有些命令比如set命令不需要返回结果都有超时的情况,所以排除掉了返回结果过大的问题。

再次深入思考golang 里的读超时触发过程

go协程在碰到网络读取时,协程会挂起,等待网络包到达后,由go runtime唤醒协程,然后协程继续进行读取操作,当唤醒时会检查超时时间,如果到达了超时限制,那么将直接报读超时错误。(有机会可以详细分析下golang的netpoll源码) 源码如下,

src/runtime/netpoll.go:303
for !netpollblock(pd, int32(mode), false) {
		errcode = netpollcheckerr(pd, int32(mode))
		if errcode != pollNoError {
			return errcode
		}
		// Can happen if timeout has fired and unblocked us,
		// but before we had a chance to run, timeout has been reset.
		// Pretend it has not happened and retry.
	}

netpollblock 不阻塞协程时,首先执行了netpollcheckerr,netpollcheckerr检查是否有超时情况发生。

从唤醒到协程被调度执行的这个时间称为协程的调度延迟,如果这个延迟过高,那么是有可能发生读超时的。
于是我又看了go进程中协程的调度延迟,在golang里 内置了一个/sched/latencies:seconds 指标,代表协程调度延迟,目前的prometheus client 已经对这个指标进行了兼容,所以我们是直接利用它 将延迟耗时在grafana里进行了展示。

超时期间,grafana里的 协程调度延迟只有几毫秒。而超时时间设置的200ms,显然也不是协程调度延迟的问题。

用上终极武器-抓包

以上的思路都行不通了,所以只能用上终极武器,抓包。 看看触发200ms超时时 究竟是哪些包没有到达。因为只能在客户端测抓包,所以接下来的抓包文件都是客户端测的抓包。

很对时候抓包都是解决问题特别是网络问题的最终手段,你能通过抓包清楚的看到客户端,服务端在做什么事情。

为了百分百确认并且定位问题,我一共抓取了3个文件,首先来看下第一个文件。
超时时间为200ms时的抓包文件

6379端口号是目的端口,也就是redis的端口,36846是我客户端的端口。

从抓包文件中,发现760054号报文发生了超时重传,如果客户端发了包,但是
服务端没有回应ack消息就会触发超时重传
,重传之后,客户端也没有收到服务端的消息,并且可以看到发送挥手信号和前一个正常发送的包之间刚好是隔了差不多200ms,而200ms正是客户端设置的超时时间,应用层触发超时后,将调用close方法关闭链接,所以在760055号包里 客户端发送了一个fin 挥手信号代表客户端要关闭链接了。 客户端发送fin信号挥手之后呢,服务端才发来携带数据的ack消息,不过由于此时客户端已经处于要关闭状态,所以直接发送rst信号了。

整个发包和接包流程可以用下面的流程图来展示。

image.png

接着来看第二个抓包文件。
200ms抓包分析

抓包中出现大量TCP Dup Ack 的消息,客户端一直在向端口为6379的服务端发送ack的序号为 13364573,代表客户端已经接收到服务端序号13364573之前的包了,然而服务端连续发送的包序号seq都大于了13364573 ,所以客户端认为服务端序号seq是13364573的包丢了,所以随着服务端每一次发送消息过来,都告诉服务端,我应该接收序号是13364573开始的包,赶紧发送过来。

最终在1777232号包,客户端又一次发送了TCP Dup Ack 消息,
催促服务端赶紧把丢掉的包发过来,不过这次服务端连回应都不回应了,最终触发了客户端应用层200ms的超时时间
,调用close方法关闭了连接。所以在1778166号包里,可以看到客户端发送fin挥手信号,1778166 号包的发送时间和1777232号包的发送时间正式相隔了200ms。

整个发包和接包流程可以用下面的流程图来展示。
image.png

再来看第三个抓包文件,第三个抓包文件是我将客户端超时时间设置为500ms后出现超时情况时抓到的。
500ms超时抓包

首先看第一个红色箭头处,也就是911752号包,它被wireshark标记为 Tcp Previous segment not captured,表示这个包之前的包没有被捕获到,说明这个包seq序号之前的包存在丢包情况。发现它前一个包也就是911751号包也是服务端发来的包,并且next seq 是18428124,说明911751号包下一个包的seq应该是18428124,但是911752的seq是18429584,
进一步说明 来自服务端的包序号在18428124到18429584之间的包存在丢包情况

接着是客户端对911751号包的ack消息,说明序号是18428124之前的包已经全部接收到。然后接受到了911754号这个来自服务端的包,并且这包的开始seq序号是18433964,而最近一次来自服务端的911752号包的next seq是18432504,
说明在18432504 和18433964之间,也存在服务端的丢包情况
,所以911754号包也被标记为了Tcp Previous segment not captured。

接下来就是服务端重传包,客户端继续回应Ack的过程,但是这个过程直到914025号时就停止了,因为整个读取服务端响应的过程从开始读 到当前时间已经达到了应用层设置的500ms,所以在应用层直接就关闭掉了这个链接了。

看到现在,我有了充足的理由相信,是云服务提供商那边的问题,中间由于网络丢包的原因,且延迟较大导致了redis的读超时。拿着这些证据也说服了他们,并最终圆满解决。

提工单,云服务商的排查支持

image.png

圆满解决

简介

此预览版添加了对使用 Blazor 组件进行服务器端呈现的初始支持。这是 Blazor 统一工作的开始,旨在使 Blazor 组件能够满足客户端和服务器端的所有 Web UI 需求。这是该功能的早期预览版,因此仍然受到一定限制,但我们的目标是无论选择如何构建应用,都能使用可重用的 Blazor 组件。

服务器端呈现 (SSR) 是指服务器生成 HTML 以响应请求。使用 SSR 的应用加载速度很快,因为渲染 UI 的所有艰苦工作都在服务器上完成,而无需下载大型 JavaScript 捆绑包。ASP.NET Core 对带有 MVC 和 Razor 页面的 SSR 现有支持,但这些框架缺乏用于构建可重用的 Web UI 片段的组件模型。这就是开拓者的用武之地!我们正在添加对使用 Blazor 组件生成服务器呈现的 UI 的支持,这些组件也可以扩展到客户端以实现丰富的交互性。

在此预览版中,可以使用 Blazor 组件执行服务器端呈现,而无需任何 .cshtml 文件。框架将发现可路由的 Blazor 组件,并将其设置为终结点。不涉及 WebAssembly 或 WebSocket 连接。你不需要加载任何JavaScript。每个请求由相应终结点的 Blazor 组件独立处理。

项目体验

首先需要安装 .NET 8预览版最新版

1.创建一个空的 ASP.NET Core web app:

dotnet new web -o WebApp
cd WebApp
  1. 在项目中添加一个简单的Razor组件:
dotnet new razorcomponent -n MyComponent
  1. 更新
    MyComponent.razor
    内容,将其变成一个带有路由的合适的HTML页面;
@page "/"
@implements IRazorComponentApplication<MyComponent>

<!DOCTYPE html>
<html lang="en">
<body>
    <h1>Hello Blazor!</h1>
    <p>The time is @DateTime.Now.ToShortTimeString()</p>
</body>
</html>

你还需要在这个组件上实现接口
IRazorComponentApplication
,该接口目前用于帮助发现应用程序中的组件端点。这个设计可能会在以后的更新中改变,但目前这个接口是必需的。


  1. Program.cs
    中通过调用
    .AddRazorComponents()
    设置
    Razor
    组件服务

    builder.Services.AddRazorComponents();
    
  2. 通过调用映射组件的端点。你需要为你的组件添加一个using指令:
    MapRazorComponents<TComponent>()

app.MapRazorComponents<WebApp.MyComponent>();

可路由组件将自动在所驻留的程序集中发现。再次注意,当前必须实现,但此设计可能会在以后的更新中更改。MyComponentTComponentIRazorComponentApplication

  1. 运行应用程序并浏览到应用程序根目录查看你的组件渲染

但是似乎无法交互,我在使用了点击事件但是没法触发了

修改
MyComponent.razor
文件代码

@page "/"
@implements IRazorComponentApplication<MyComponent>

<!DOCTYPE html>
<html lang="en">

<body>
    <h1>Hello Blazor!</h1>
    <p>The time is @DateTime.Now.ToShortTimeString()</p>
    <p>Counter: @counter</p>
    <button @onclick="OnClick">run</button>
    @code {
        private int counter = 0;
        private void OnClick()
        {
            counter++;
        }
    }
</body>

</html>

运行效果:

当我们点击按钮并不会触发事件!可能是需要写js去完成,欢迎大佬一块讨论讨论新的技术

来着token的分享

技术交流:737776595

快速搭建一个go语言web后端服务脚手架
源码:
https://github.com/weloe/go-web-demo

web框架使用gin,数据操作使用gorm,访问控制使用casbin

首先添加一下自定义的middleware

recover_control.go ,统一处理panic error返回的信息

package middleware

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"go-web-demo/component"
	"log"
	"net/http"
)

func Recover(c *gin.Context) {
	defer func() {
		if r := recover(); r != nil {
			// print err msg
			log.Printf("panic: %v\n", r)
			// debug.PrintStack()
			// response same struct
			c.JSON(http.StatusBadRequest, component.RestResponse{Code: -1, Message: fmt.Sprintf("%v", r)})
		}
	}()

	c.Next()
}

access_control.go 使用casbin进行访问控制的中间件

package middleware

import (
	"fmt"
	"github.com/casbin/casbin/v2"
	gormadapter "github.com/casbin/gorm-adapter/v3"
	"github.com/gin-gonic/gin"
	_ "github.com/go-sql-driver/mysql"
	"go-web-demo/component"
	"log"
	"net/http"
)

// DefaultAuthorize determines if current subject has been authorized to take an action on an object.
func DefaultAuthorize(obj string, act string) gin.HandlerFunc {
	return func(c *gin.Context) {

		// Get current user/subject
		token := c.Request.Header.Get("token")
		if token == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, component.RestResponse{Message: "token is nil"})
			return
		}
		username, err := component.GlobalCache.Get(token)
		if err != nil || string(username) == "" {
			log.Println(err)
			c.AbortWithStatusJSON(http.StatusUnauthorized, component.RestResponse{Message: "user hasn't logged in yet"})
			return
		}

		// Casbin enforces policy
		ok, err := enforce(string(username), obj, act, component.Enforcer)
		if err != nil {
			log.Println(err)
			c.AbortWithStatusJSON(http.StatusInternalServerError, component.RestResponse{Message: "error occurred when authorizing user"})
			return
		}
		if !ok {
			c.AbortWithStatusJSON(http.StatusForbidden, component.RestResponse{Message: "forbidden"})
			return
		}

		c.Next()
	}
}

func enforce(sub string, obj string, act string, enforcer *casbin.Enforcer) (bool, error) {
	// Load policies from DB dynamically
	err := enforcer.LoadPolicy()
	if err != nil {
		return false, fmt.Errorf("failed to load policy from DB: %w", err)
	}
	// Verify
	ok, err := enforcer.Enforce(sub, obj, act)
	return ok, err
}

func AuthorizeAdapterAndModel(obj string, act string, adapter *gormadapter.Adapter, model string) gin.HandlerFunc {
	return func(c *gin.Context) {

		// Get current user/subject
		token := c.Request.Header.Get("token")
		if token == "" {
			c.AbortWithStatusJSON(401, component.RestResponse{Message: "token is nil"})
			return
		}
		username, err := component.GlobalCache.Get(token)
		if err != nil || string(username) == "" {
			log.Println(err)
			c.AbortWithStatusJSON(401, component.RestResponse{Message: "user hasn't logged in yet"})
			return
		}

		// Load model configuration file and policy store adapter
		enforcer, err := casbin.NewEnforcer(model, adapter)
		// Casbin enforces policy
		ok, err := enforce(string(username), obj, act, enforcer)

		if err != nil {
			log.Println(err)
			c.AbortWithStatusJSON(500, component.RestResponse{Message: "error occurred when authorizing user"})
			return
		}
		if !ok {
			c.AbortWithStatusJSON(403, component.RestResponse{Message: "forbidden"})
			return
		}

		c.Next()
	}
}

reader.go 读取yaml配置文件的根据类,使用了viter

package config

import (
	"fmt"
	"github.com/spf13/viper"
	"log"
	"sync"
	"time"
)

type Config struct {
	Server     *Server
	Mysql      *DB
	LocalCache *LocalCache
	Casbin     *Casbin
}

type Server struct {
	Port int64
}

type DB struct {
	Username string
	Password string
	Host     string
	Port     int64
	Dbname   string
	TimeOut  string
}

type LocalCache struct {
	ExpireTime time.Duration
}

type Casbin struct {
	Model string
}

var (
	once   sync.Once
	Reader = new(Config)
)

func (config *Config) ReadConfig() *Config {
	once.Do(func() {
		viper.SetConfigName("config")   // filename
		viper.SetConfigType("yaml")     // filename extension : yaml | json |
		viper.AddConfigPath("./config") // workspace dir : ./
		var err error
		err = viper.ReadInConfig() // read config
		if err != nil {            // handler err
			log.Fatalf(fmt.Sprintf("Fatal error config file: %s \n", err))
		}
		err = viper.Unmarshal(config)
		if err != nil {
			log.Fatalf(fmt.Sprintf("Fatal error viper unmarshal config: %s \n", err))
		}
	})
	return Reader
}

配置文件

server:
  port: 8080

mysql:
  username: root
  password: pwd
  host: 127.0.0.1
  port: 3306
  dbname: casbin_demo
  timeout: 10s

localCache:
  expireTime: 60

casbin:
  model: config/rbac_model.conf

persistence.go, gorm,bigcache, casbin 初始化,这里用的casbin是从数据库读取policy

package component

import (
	"fmt"
	"github.com/allegro/bigcache"
	"github.com/casbin/casbin/v2"
	gormadapter "github.com/casbin/gorm-adapter/v3"
	_ "github.com/go-sql-driver/mysql"
	"go-web-demo/config"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"log"
	"time"
)

var (
	DB          *gorm.DB
	GlobalCache *bigcache.BigCache
	Enforcer    *casbin.Enforcer
)

// CreateByConfig create components
func CreateByConfig() {

	ConnectDB()

	CreateLocalCache()

	CreateCasbinEnforcer()
}

func ConnectDB() {
	// connect to DB
	var err error
	dbConfig := config.Reader.ReadConfig().Mysql
	if dbConfig == nil {
		log.Fatalf(fmt.Sprintf("db config is nil"))
	}
	// config
	username := dbConfig.Username
	password := dbConfig.Password
	host := dbConfig.Host
	port := dbConfig.Port
	Dbname := dbConfig.Dbname
	timeout := dbConfig.TimeOut

	dbUrl := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=True&loc=Local&timeout=%s", username, password, host, port, Dbname, timeout)
	log.Println("connect db url: " + dbUrl)
	DB, err = gorm.Open(mysql.Open(dbUrl), &gorm.Config{})

	if err != nil {
		log.Fatalf(fmt.Sprintf("failed to connect to DB: %v", err))
	}
}

func CreateLocalCache() {
	var err error
	cacheConfig := config.Reader.ReadConfig().LocalCache
	if cacheConfig == nil {
		log.Fatalf(fmt.Sprintf("cache config is nil"))
	}
	// Initialize cache to store current user in cache.
	GlobalCache, err = bigcache.NewBigCache(bigcache.DefaultConfig(cacheConfig.ExpireTime * time.Second)) // Set expire time to 30 s
	if err != nil {
		log.Fatalf(fmt.Sprintf("failed to initialize cahce: %v", err))
	}
}

func CreateCasbinEnforcer() {
	var err error

	// casbin model
	config := config.Reader.ReadConfig().Casbin
	if config == nil {
		log.Fatalf(fmt.Sprintf("casbin config is nil"))
	}
	model := config.Model
	//Initialize casbin adapter
	adapter, _ := gormadapter.NewAdapterByDB(DB)

	// Load model configuration file and policy store adapter
	Enforcer, err = casbin.NewEnforcer(model, adapter)
	if err != nil {
		log.Fatalf(fmt.Sprintf("failed to create casbin enforcer: %v", err))
	}
    
}

到这里准备工作基本完成,我们来写一个通用的 登录,注册,退出 业务吧

user_handler.go

package handler

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"
	"go-web-demo/component"
	"go-web-demo/handler/request"
	"go-web-demo/service"
	"net/http"
)

func Login(c *gin.Context) {
	loginRequest := &request.Login{}
	err := c.ShouldBindBodyWith(loginRequest, binding.JSON)
	if err != nil {
		panic(fmt.Errorf("request body bind error: %v", err))
	}
	token := service.Login(loginRequest)

	c.JSON(http.StatusOK, component.RestResponse{Code: 1, Data: token, Message: loginRequest.Username + " logged in successfully"})

}

func Logout(c *gin.Context) {
	token := c.Request.Header.Get("token")

	if token == "" {
		panic(fmt.Errorf("token error: token is nil"))
	}

	bytes, err := component.GlobalCache.Get(token)

	if err != nil {
		panic(fmt.Errorf("token error: failed to get username: %v", err))
	}

	username := string(bytes)
	// Authentication

	// Delete store current subject in cache
	err = component.GlobalCache.Delete(token)
	if err != nil {
		panic(fmt.Errorf("failed to delete current subject in cache: %w", err))
	}

	c.JSON(http.StatusOK, component.RestResponse{Code: 1, Data: token, Message: username + " logout in successfully"})
}

func Register(c *gin.Context) {
	register := &request.Register{}
	err := c.ShouldBindBodyWith(register, binding.JSON)
	if err != nil {
		c.JSON(400, component.RestResponse{Code: -1, Message: " bind error"})
		return
	}

	service.Register(register)

	c.JSON(http.StatusOK, component.RestResponse{Code: 1, Data: nil, Message: "register successfully"})
}

service.user.go

这里要注意 注册的时候我们做了两个操作,注册到user表,把policy写入到casbin_rule表,要保证他们要同时成功,所以要用事务

func Login(loginRequest *request.Login) string {
	password := loginRequest.Password
	username := loginRequest.Username

	// Authentication
	user := dao.GetByUsername(username)
	if password != user.Password {
		panic(fmt.Errorf(username + " logged error : password error"))
	}

	// Generate random uuid token
	u, err := uuid.NewRandom()
	if err != nil {
		panic(fmt.Errorf("failed to generate UUID: %w", err))
	}
	// Sprintf token
	token := fmt.Sprintf("%s-%s", u.String(), "token")
	// Store current subject in cache
	err = component.GlobalCache.Set(token, []byte(username))
	if err != nil {
		panic(fmt.Errorf("failed to store current subject in cache: %w", err))
	}
	// Send cache key back to client cookie
	//c.SetCookie("current_subject", token, 30*60, "/resource", "", false, true)
	return token
}

func Register(register *request.Register) {
	var err error
	e := component.Enforcer
	err = e.GetAdapter().(*gormadapter.Adapter).Transaction(e, func(copyEnforcer casbin.IEnforcer) error {
		// Insert to table
		db := copyEnforcer.GetAdapter().(*gormadapter.Adapter).GetDb()
		res := db.Exec("insert into user (username,password) values(?,?)", register.Username, register.Password)

		//User has Username and Password
		//res := db.Table("user").Create(&User{
		//	Username: register.Username,
		//	Password: register.Password,
		//})

		if err != nil || res.RowsAffected < 1 {
			return fmt.Errorf("insert error: %w", err)
		}

		_, err = copyEnforcer.AddRoleForUser(register.Username, "role::user")
		if err != nil {
			return fmt.Errorf("add plocy error: %w", err)
		}
		return nil
	})

	if err != nil {
		panic(err)
	}

}

dao.user.go 对数据库的操作

package dao

import "go-web-demo/component"

type User struct {
	Id       int64 `gorm:"primaryKey"`
	Username string
	Password string
	Email    string
	Phone    string
}

func (u *User) TableName() string {
	return "user"
}

func GetByUsername(username string) *User {
	res := new(User)
	component.DB.Model(&User{}).Where("username = ?", username).First(res)
	return res
}

func Insert(username string, password string) (int64, error, int64) {
	user := &User{Username: username, Password: password}
	res := component.DB.Create(&user)

	return user.Id, res.Error, res.RowsAffected
}

最后一步,启动web服务,配置路由

package main

import (
	"fmt"
	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"go-web-demo/component"
	"go-web-demo/config"
	"go-web-demo/handler"
	"go-web-demo/middleware"
	"log"
)

var (
	router *gin.Engine
)

func init() {
	//Initialize components from config yaml: mysql locaCache casbin
	component.CreateByConfig()

	// Initialize gin engine
	router = gin.Default()

	// Initialize gin middleware
	corsConfig := cors.DefaultConfig()
	corsConfig.AllowAllOrigins = true
	corsConfig.AllowCredentials = true
	router.Use(cors.New(corsConfig))
	router.Use(middleware.Recover)

	// Initialize gin router
	user := router.Group("/user")
	{
		user.POST("/login", handler.Login)
		user.POST("/logout", handler.Logout)
		user.POST("/register", handler.Register)
	}

	resource := router.Group("/api")
	{
		resource.Use(middleware.DefaultAuthorize("user::resource", "read-write"))
		resource.GET("/resource", handler.ReadResource)
		resource.POST("/resource", handler.WriteResource)
	}

}

func main() {
	// Start
	port := config.Reader.Server.Port
	err := router.Run(":" + port)
	if err != nil {
		panic(fmt.Sprintf("failed to start gin engine: %v", err))
	}
	log.Println("application is now running...")
}

表结构和相关测试数据

CREATE DATABASE /*!32312 IF NOT EXISTS*/`casbin_demo` /*!40100 DEFAULT CHARACTER SET utf8 */;

USE `casbin_demo`;

/*Table structure for table `casbin_rule` */

DROP TABLE IF EXISTS `casbin_rule`;

CREATE TABLE `casbin_rule` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `ptype` varchar(100) NOT NULL,
  `v0` varchar(100) DEFAULT NULL,
  `v1` varchar(100) DEFAULT NULL,
  `v2` varchar(100) DEFAULT NULL,
  `v3` varchar(100) DEFAULT NULL,
  `v4` varchar(100) DEFAULT NULL,
  `v5` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_casbin_rule` (`v0`,`v1`,`v2`,`v3`,`v4`,`v5`)
) ENGINE=InnoDB AUTO_INCREMENT=64 DEFAULT CHARSET=utf8;

/*Data for the table `casbin_rule` */

insert  into `casbin_rule`(`id`,`ptype`,`v0`,`v1`,`v2`,`v3`,`v4`,`v5`) values 

(3,'p','role::admin','admin::resource','read-write','','',''),

(5,'p','role::user','user::resource','read-write','','',''),

(57,'g','test1','role::user','','','',''),

(59,'g','role::admin','role::user','','','',''),

(63,'g','test2','role::admin',NULL,NULL,NULL,NULL);

/*Table structure for table `user` */

DROP TABLE IF EXISTS `user`;

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL,
  `password` varchar(50) DEFAULT NULL,
  `email` varchar(50) DEFAULT NULL,
  `phone` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=39 DEFAULT CHARSET=utf8;

/*Data for the table `user` */

insert  into `user`(`id`,`username`,`password`,`email`,`phone`) values 

(36,'test1','123',NULL,NULL),

(38,'test2','123',NULL,NULL);

用法

  • 隔离各个线程间的数据
  • 避免线程内每个方法都进行传参,线程内的所有方法都可以直接获取到
    ThreadLocal
    中管理的对象。
package com.example.test1.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.Date;

@Component
public class AsyncTest {

    // 使用threadlocal管理
    private static final ThreadLocal<SimpleDateFormat> dateFormatLocal =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    // 不用threadlocal进行管理,用于对比
    SimpleDateFormat dateFormat = new SimpleDateFormat();

    // 线程名称以task开头
    @Async("taskExecutor")
    public void formatDateSync(String format, Date date) throws InterruptedException {
        SimpleDateFormat simpleDateFormat = dateFormatLocal.get();
        simpleDateFormat.applyPattern(format);
        
        // 所有方法都可以直接使用这个变量,而不用根据形参传入
        doSomething();
        
        Thread.sleep(1000);
        System.out.println("sync " + Thread.currentThread().getName() +  " | " + simpleDateFormat.format(date));
        
        // 线程执行完毕,清除数据
        dateFormatLocal.remove();
    }

    // 线程名称以task2开头
    @Async("taskExecutor2")
    public void formatDate(String format, Date date) throws InterruptedException {
        dateFormat.applyPattern(format);
        Thread.sleep(1000);
        System.out.println("normal " + Thread.currentThread().getName() +  " | " + dateFormat.format(date));
    }
}

使用
junit
进行测试:

	@Test
	void test2() throws InterruptedException {
		for(int index = 1; index <= 10; ++index){
			String format = index + "-yyyy-MM-dd";
			Date time = new Date();
			asyncTest.formatDate(format, time);
		}

		for(int index = 1; index <= 10; ++index){
			String format = index + "-yyyy-MM-dd";
			Date time = new Date();
			asyncTest.formatDateSync(format, time);
		}
	}

结果如下,可以看到没有被
ThreadLocal
管理的变量已经无法匹配正确的format。

sync task--10 | 10-2023-04-11
sync task--9 | 9-2023-04-11
normal task2-3 | 2-2023-04-11
normal task2-5 | 2-2023-04-11
normal task2-10 | 2-2023-04-11
normal task2-6 | 2-2023-04-11
sync task--1 | 1-2023-04-11
normal task2-7 | 2-2023-04-11
normal task2-8 | 2-2023-04-11
normal task2-9 | 2-2023-04-11
sync task--6 | 6-2023-04-11
sync task--3 | 3-2023-04-11
sync task--2 | 2-2023-04-11
sync task--7 | 7-2023-04-11
sync task--4 | 4-2023-04-11
sync task--8 | 8-2023-04-11
normal task2-4 | 2-2023-04-11
normal task2-1 | 2-2023-04-11
sync task--5 | 5-2023-04-11
normal task2-2 | 2-2023-04-11

实现原理


ThreadLocal
中获取数据的过程:

  • 先获取对应的线程。
  • 通过
    getMap(t)
    拿到线程中的
    ThreadLocalMap
  • ThreadLocalMap
    是一个重新实现的散列表,基于两个元素实现散列:
    • 用户定义的
      ThreadLocal
      对象,例如:
      dateFormatLocal
    • 封装了
      value

      Entry
      对象。
  • 通过
    map.getEntry(this)
    方法,根据当前的
    threadlocal
    对象在散列表中获得对应的
    Entry
  • 如果是第一次使用
    get()
    , 则使用
    setInitialValue()
    调用用户重写的
    initialValue()
    方法创建map并使用用户指定的值初始化。

在这种设计方式下,线程死去的时候,线程共享变量
ThreadLocalMap
会被销毁。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

注意
Entry
对象是弱引用:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    // k: ThreadLocal, v: value
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

弱引用的常见用法是:

WeakReference<RoleDTO> weakReference = new WeakReference<>(new RoleDTO());

因此,在
Entry
中,
k
代表
ThreadLocal
对象,它是弱引用。v代表
ThreadLocal
管理的那个
value
,是强引用。

内存泄漏

内存泄漏
是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费称为内存泄漏。随着垃圾回收器活动的增加以及内存占用的不断增加,程序性能会逐渐表现出来下降,极端情况下,会引发
OutOfMemoryError
导致程序崩溃。

内存泄漏问题主要在线程池中出现,因为线程池中的线程是不断执行的,从任务队列中不断获取新的任务执行。但是任务中可能有
ThreadLocal
对象,这些对象的
ThreadLocal
会保存在线程的
ThreadLocalMap
中,因此
ThreadLocalMap
会越来越大。

但是
ThreadLocal
是由任务(worker)传入的,一个任务执行结束后,对应的
ThreadLocal
对象会被销毁。线程中的关系是:
Thread -> ThreadLoalMap -> Entry<ThreadLocal, Object>

ThreadLocal
由于是弱引用会,在GC的时候会被销毁,这会导致
ThreadLoalMap
中存在
Entry<null, Object>

使用
remove()

由于线程池中的线程一直在运行,如果不对
ThreadLoalMap
进行清理,那
Entry<null, Object>
会一直占用内存。
remove()
方法会清除
key==null

Entry

使用static修饰


ThreadLocal
设置成
static
可以避免一个线程类多次传入线程池后重复创建
Entry
。例如,有一个用户定义的线程

public class Test implements Runnable{
    private static ThreadLocal<Integer> local = new ThreadLocal<>();
    @Override
    public void run() {
        // do something
    }
}

使用线程池处理10个任务。那么线程池中每个用来处理任务的线程的
Thread.ThreadLocalMap
中都会保存一个
Entry<local, Integer>
,由于添加了
static
关键字,所有每个线程中的
Entry
中的
local
变量引用的都是同一个变量。这时就算发生内存泄漏,所有的Test类也只有一个
local
对象,不会导致内存占用过多。

@Test
void contextLoads() {
   Runnable runnable = () -> {
      System.out.println(Thread.currentThread().getName());
   };

   for(int index = 1; index <= 10; ++index){
      taskExecutor2.submit(new com.example.test1.service.Test());
   }
}

@


前端代码的框架采用vue.js + elementUI 这套较为简单的方式实现,以及typescript语法更方便阅读。

首先添加全局对象:

loginForm: 登录表单对象
twoFactorData: 两步验证数据,
showTwoFactorSuccess: 是否显示两步验证成功提示

loginForm: {
    //登录对象
    username: "",
    password: "",
    twoFactorAuthenticationToken: "",
    twoFactorAuthenticationProvider: "Phone",
},
twoFactorData: null,
showTwoFactorSuccess: false,

发送验证码

编写发送验证码函数sendVerificationCode,发送验证码后,启动定时器,60秒后可以再次发送验证码。

   async sendVerificationCode() {
      this.smsSendCd = 60;
      this.timer = setInterval(() => {
        this.smsSendCd--;
        if (this.smsSendCd <= 0) {
          clearInterval(this.timer);
        }
      }, 1000);
      await request(
        `${this.host}api/TokenAuth/SendTwoFactorAuthenticateCaptcha`,
        "post",
        {
          provider: "Phone",
          userId: this.twoFactorData.userId,
        }
      )
        .catch((re) => {
          var res = re.response.data;
          this.errorMessage(res.error.message);
        })
        .then((re) => {
          var res = re.data.result;
          this.showTwoFactorSuccess = true;
          this.showTwoFactorSuccess = false;
          this.successMessage("发送验证码成功");
        });
    },

request 是利用axios库发送带有访问凭证Header请求功能的封装 ,ajaxRequest.ts请参考博文
使用 Abp.Zero 搭建第三方登录模块(三):网页端开发

这里使用js-cookie库获取cookie中的访问凭证,并添加到Header中

import { request } from "@/ajaxRequire";
import Cookies from "js-cookie";

const tokenKey = "main_token";
const setToken = (token: string) => Cookies.set(tokenKey, token);
const cleanToken = () => Cookies.remove(tokenKey);
const getToken = () => Cookies.get(tokenKey);

登录

编写登录函数handleLogin:

 async handleLogin() {
      this.loading = true;

      var userNameOrEmailAddress = this.loginForm.username;
      var password = this.loginForm.password;

      var twoFactorAuthenticationToken =
        this.loginForm.twoFactorAuthenticationToken;
      var twoFactorAuthenticationProvider =
        this.loginForm.twoFactorAuthenticationProvider;

      userNameOrEmailAddress = userNameOrEmailAddress.trim();
      await request(`${this.host}api/TokenAuth/Authenticate`, "post", {
        userNameOrEmailAddress,
        password,
        twoFactorAuthenticationToken,
        twoFactorAuthenticationProvider,
      })
        .catch((re) => {
          var res = re.response.data;
          this.errorMessage(res.error.message);
        })
        .then(async (res) => {
          var data = res.data.result;
          if (data.requiresTwoFactorAuthenticate) {
            this.twoFactorData = data;
          } else {
            setToken(data.accessToken);
            setRememberClientToken(data.rememberClientToken);
            await this.getCurrentUser();
          }
        })
        .finally(() => {
          setTimeout(() => {
            this.loading = false;
          }, 1.5 * 1000);
        });
    },

请注意,当需要进行两步验证时,requiresTwoFactorAuthenticate会返回true,同时返回
twoFactorAuthenticationProviders。

退出登录

登出, 将Token以及用户信息置空

<el-button
    :loading="loading"
    type="danger"
    style="width: 100%"
    @click.native.prevent="logout">
    退出登录
</el-button>
logout() {
    setToken(null);
    this.token = null;
    this.userInfo = null;
},

界面控件

在登录表单的HTML中,添加两步验证控件:
显示规则为,当需要两步验证时(即twoFactorData不为空),显示两步验证控件,否则显示登录控件。

根据twoFactorAuthenticationProviders。我们采用了两种方式,一种是短信验证码,一种是邮箱验证码,这里我们采用了elementUI的tab组件,来实现两种方式的切换。

     <el-form
        ref="loginForm"
        :model="loginForm"
        class="login-form"
        autocomplete="on"
        label-position="left"
      >
        <template v-if="twoFactorData == null">
            ...
        </template>
        <template v-else>
          <p>您的账号开启了两步验证,请选择一种认证方式以继续登录</p>
          <el-tabs
            v-model="loginForm.twoFactorAuthenticationProvider"
            tab-position="top"
          >
            <el-tab-pane
              :lazy="true"
              label="SMS短信验证"
              name="Phone"
              :disabled="
                twoFactorData.twoFactorAuthenticationProviders.indexOf(
                  'Email'
                ) == -1
              "
            >
              <el-row>
                <el-col
                  :span="24"
                  style="
                     {
                      margin-bottom: 10px;
                    }
                  "
                >
                  <el-alert
                    v-if="showTwoFactorSuccess"
                    title="验证码已发送至用户的手机号,请查收"
                    type="info"
                  >
                  </el-alert>
                </el-col>
                <el-col :span="24">
                  <el-form-item
                    class="item"
                    prop="twoFactorAuthenticationToken"
                  >
                    <el-input
                      v-model="loginForm.twoFactorAuthenticationToken"
                      :placeholder="'发送验证码后键入验证码'"
                      tabindex="2"
                      autocomplete="on"
                      @blur="capsTooltip = false"
                    >
                      <el-button
                        slot="append"
                        :disabled="smsSendCd > 0"
                        @click="sendVerificationCode"
                        >{{
                          smsSendCd == 0 ? "发送验证码" : smsSendCd + "后重试"
                        }}</el-button
                      >
                    </el-input>
                  </el-form-item>
                </el-col>
              </el-row>
            </el-tab-pane>

            <el-tab-pane
              :lazy="true"
              label="邮箱验证"
              name="Email"
              :disabled="
                twoFactorData.twoFactorAuthenticationProviders.indexOf(
                  'Email'
                ) == -1
              "
            >
              <el-row>
                <el-col :span="24">
                  <el-alert
                    v-if="showTwoFactorSuccess"
                    title="验证码已发送至登录用户对应的邮箱,请查收"
                    type="info"
                  >
                  </el-alert>
                </el-col>
                <el-col :span="24">
                ...
                </el-col>
              </el-row>
            </el-tab-pane>
          </el-tabs>
        </template>

        <el-row type="flex" class="row-bg" justify="center" :gutter="10">
          <el-col :span="10" v-if="twoFactorData != null">
            <el-button
              :loading="loading"
              style="width: 100%"
              @click.native.prevent="twoFactorData = null"
            >
              返回
            </el-button>
          </el-col>
          <el-col :span="10">
            <el-button
              :loading="loading"
              type="primary"
              style="width: 100%"
              @click.native.prevent="handleLogin"
            >
              {{ twoFactorData == null ? "登录" : "继续" }}
            </el-button>
          </el-col>
        </el-row>
      </el-form>

在这里插入图片描述

获取用户信息功能

登录成功后我们要拿到当前用户的信息,存入userInfo对象,并在页面上简单展示

<span>{{ userInfo }}</span>

创建一个获取当前用户的函数

async getCurrentUser() {
    await request(
    `${this.host}${this.prefix}/User/GetCurrentUser`,
    "get",
    null
    )
    .catch((re) => {
        var res = re.response.data;
        this.errorMessage(res.error.message);
    })
    .then(async (re) => {
        var result = re.data.result as any;
        this.userInfo = result;
        this.token = getToken();
        clearInterval(this.timer);

        this.smsSendCd = 0;
        this.currentVerifyingType = null;

        this.successMessage("登录成功");
    });
}

最终效果

在这里插入图片描述

项目地址

Github:matoapp-samples