用户模块
user表结构设计
sql
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`username` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`password` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`email` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`gender` tinyint(4) NOT NULL DEFAULT '0',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`) USING BTREE,
UNIQUE KEY `idx_user_id` (`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
- 整句拆解
| SQL 片段 | 含义 |
|---|---|
DROP TABLE IF EXISTS user`` |
如果表 user 已经存在,则先删除它。这是为了在重新执行建表脚本时避免冲突,常用于开发和测试环境。 |
CREATE TABLE user(...) |
创建名为 user 的新表,括号内是表的字段定义和约束。 |
ENGINE=InnoDB |
指定使用 InnoDB 存储引擎。这是 MySQL 默认且最常用的引擎,支持事务、行级锁和外键。 |
DEFAULT CHARSET=utf8mb4 |
指定字符集为 utf8mb4 。它是 utf8 的完整实现,支持存储表情符号(如 😊)和所有 Unicode 字符,是目前最推荐的字符集。 |
COLLATE=utf8mb4_general_ci |
指定排序规则为 utf8mb4_general_ci。ci 表示 Case Insensitive (大小写不敏感),即查询时 'abc' 和 'ABC' 会被视为相同。 |
英语动词collate,在数据库领域,它专门用来指字符串的比较和排序规则。
- 字段详解
| 字段名 | 类型 | 约束 | 含义 | 为什么这样设计? |
|---|---|---|---|---|
id |
bigint(20) |
NOT NULL AUTO_INCREMENT |
主键 ID,自增 | 数据库内部自增主键 ,用于保证每行数据的物理唯一性,与业务无关。AUTO_INCREMENT 让 MySQL 自动分配一个递增的数字。 |
user_id |
bigint(20) |
NOT NULL |
用户 ID | 业务层面的用户唯一标识 。为什么不直接用 id?因为 id 是自增的,会暴露用户总量和增长趋势,且不适合作为分布式环境下的业务 ID。user_id 通常由分布式 ID 生成器(如雪花算法)生成,保证全局唯一且不连续。 |
username |
varchar(64) |
NOT NULL |
用户名 | 长度 64 足够容纳常见用户名,且建立唯一索引,确保用户名不重复。 |
password |
varchar(64) |
NOT NULL |
密码 | 长度 64 足够存储加密后的密码(如 bcrypt 或 MD5 加盐后的十六进制字符串通常为 32 或 64 位)。 |
email |
varchar(64) |
DEFAULT NULL |
邮箱 | 允许为空,因为用户注册时可能不提供邮箱,或者留作后续补充。 |
gender |
tinyint(4) |
NOT NULL DEFAULT '0' |
性别 | tinyint 占用 1 字节,存储 0/1/2 表示未知/男/女,非常高效。默认 0 表示未设置。 |
create_time |
timestamp |
DEFAULT CURRENT_TIMESTAMP |
创建时间 | 自动记录插入数据时的当前时间,无需代码手动赋值。 |
update_time |
timestamp |
DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
更新时间 | 自动记录数据每次更新时的当前时间,非常适合追踪记录的最后修改时刻。 |
- 索引说明
| 索引名 | 类型 | 字段 | 作用 |
|---|---|---|---|
PRIMARY KEY (id) |
主键索引 | id |
保证每行数据物理唯一,且查询速度最快。 |
idx_username |
指定唯一索引 | username |
确保用户名全局唯一 ,并且使用 USING BTREE指定 B+树索引,加速 WHERE username = ? 的查询。 |
idx_user_id |
指定唯一索引 | user_id |
确保业务用户 ID 全局唯一 ,并且加速通过 user_id 查询用户的操作。 |
主键索引是唯一的,所以不用指定索引名,唯一索引不是唯一的,所以需要指定索引名,唯一索引只负责确保字段值不重复。
- 为什么不直接用
id作为业务用户 ID?
这是很多初学者的疑问。区分 id 和 user_id 的设计,体现了"主键用于数据库内部,业务 ID 用于对外暴露"的最佳实践。
| 对比维度 | id(自增主键) |
user_id(分布式业务 ID) |
|---|---|---|
| 生成方式 | 数据库自动递增 | 应用层生成(如雪花算法) |
| 是否连续 | 连续递增 | 全局唯一但趋势递增 |
| 是否暴露给用户 | ❌ 从不暴露 | ✅ 对外使用(如 API 路径 /user/123456) |
| 安全性 | 低,容易猜测用户数量 | 高,难以遍历和猜测 |
| 分布式友好 | 否,分库分表后无法保证全局唯一 | 是,天生适合分布式环境 |
在 API 响应中,通常返回 user_id 而不是 id,这样既保护了数据库内部结构,也提升了系统安全性。
分布式ID生成器
分布式ID的特点
- 全局唯一性:不能出现有重复的ID标识,这是基本要求。
- 递增性:确保生成ID对于用户或业务是递增的。
- 高可用性:确保任何时候都能生成正确的ID。
- 高性能性:在高并发的环境下依然表现良好。
不仅仅是用于用户ID,实际互联网中有很多场景需要能够生成类似MySQL自增ID这样不断增大,同时又不会重复的id。以支持业务中的高并发场景。
比较典型的场景有:电商促销时短时间内会有大量的订单涌入到系统,比如每秒10w+;明星出轨时微博短时间内会产生大量的相关微博转发和评论消息。在这些业务场景下将数据插入数据库之前,我们需要给这些订单和消息先分配一个唯一ID,然后再保存到数据库中。对这个id的要求是希望其中能带有一些时间信息,这样即使我们后端的系统对消息进行了分库分表,也能够以时间顺序对这些消息进行排序。
雪花算法(Snowflake)
它是Twitter开源的由64位整数组成分布式ID,性能较高,并且在单机上递增。

位分配说明
- 第一位占用1bit,其值始终是0,没有实际作用。
- 时间戳占用41bit,单位为毫秒,总共可以容纳约69年的时间。这里的时间戳只是相对于某个时间的增量,比如系统上线是2020-07-01,那么timestamp当作是从2020-07-01 00:00:00.000的偏移量。
- 工作机器id占用10bit,其中高位5bit是数据中心ID,低位5bit是工作节点ID,最多可以容纳1024个节点。
- 序列号占用12bit,用来记录同毫秒内产生的不同id。每个节点每毫秒从0开始不断累加,最多可以累加到4095,同一毫秒一共可以产生4096个ID。
容量计算
同一毫秒的ID数量 = 1024 × 4096 = 4194304
Snowflake的Go实现
1. bwmarrin/snowflake
github.com/bwmarrin/sn... 是一个相当轻量化的snowflake的Go实现。

go
package main
import (
"fmt"
"time"
"github.com/bwmarrin/snowflake"
)
var node *snowflake.Node
func Init(startTime string, machineID int64) (err error) {
var st time.Time
st, err = time.Parse("2006-01-02", startTime)
if err != nil {
return
}
snowflake.Epoch = st.UnixNano() / 1000000
node, err = snowflake.NewNode(machineID)
return
}
func GenID() int64 {
return node.Generate().Int64()
}
func main() {
if err := Init("2020-07-01", 1); err != nil {
fmt.Println("init failed, err:", err)
return
}
id := GenID()
fmt.Println(id)
}
2. sony/sonyflake
sony/sonyflake 是Sony公司的一个开源项目,基本思路和snowflake差不多,不过位分配上有不同:

这里的时间只用了39个bit,但时间的单位变成了10ms,所以理论上比41位表示的时间还要久(174年)。
Sequence ID和之前的定义⼀一致, Machine ID其实就是节点id。 sonyflake库有以下配置参数:
go
type Settings struct {
StartTime time.Time
MachineID func() (uint16, error)
CheckMachineID func(uint16) bool
}
- StartTime:和Epoch类似,如果不设置,默认从2014-09-01 00:00:00 +0000 UTC开始。
- MachineID:可由用户自定义函数,如果不定义,默认将本机IP的低16位作为machine id。
- CheckMachineID:由用户提供的检查MachineID是否冲突的函数。
使用示例
go
package main
import (
"fmt"
"time"
"github.com/sony/sonyflake"
)
var (
sonyFlake *sonyflake.Sonyflake
sonyMachineID uint16
)
func getMachineID() (uint16, error) {
return sonyMachineID, nil
}
// Init 需传入当前的机器ID
func Init(startTime string, machineId uint16) (err error) {
sonyMachineID = machineId
var st time.Time
st, err = time.Parse("2006-01-02", startTime)
if err != nil {
return err
}
settings := sonyflake.Settings{
StartTime: st,
MachineID: getMachineID,
}
sonyFlake = sonyflake.NewSonyflake(settings)
return
}
// GenID 生成id
func GenID() (id uint64, err error) {
if sonyFlake == nil {
err = fmt.Errorf("sonyflake not initied")
return
}
id, err = sonyFlake.NextID()
return
}
func main() {
if err := Init("2020-07-01", 1); err != nil {
fmt.Printf("Init failed, err:%v\n", err)
return
}
id, _ := GenID()
fmt.Println(id)
}
登录注册流程
注册流程
提交注册信息 → 参数校验 → 入库 → 注册成功
登录流程
提交登录信息 → 参数校验 → 查询数据库 → 登录成功 → 下发Token
用户认证
HTTP是一个无状态的协议,一次请求结束后,下次再发送服务器就不知道这个请求是谁发来的了(同一个IP不代表同一个用户)。在Web应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案,并且各有千秋。
Cookie-Session认证模式
在Web应用发展的初期,大部分采用基于Cookie-Session的会话管理方式,逻辑如下:
- 客户端使用用户名、密码进行认证
- 服务端验证用户名、密码正确后生成并存储Session,将SessionID通过Cookie返回给客户端
- 客户端访问需要认证的接口时在Cookie中携带SessionID
- 服务端通过SessionID查找Session并进行鉴权,返回给客户端需要的数据

存在的问题:
- 服务端需要存储Session,并且由于Session需要经常快速查找,通常存储在内存或内存数据库中,同时在线用户较多时需要占用大量的服务器资源。
- 当需要扩展时,创建Session的服务器可能不是验证Session的服务器,所以还需要将所有Session单独存储并共享。
- 由于客户端使用Cookie存储SessionID,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范CSRF攻击。
Token认证模式
鉴于基于Session的会话管理方式存在上述多个缺点,基于Token的无状态会话管理方式诞生了。所谓无状态,就是服务端可以不再存储信息,甚至是不再存储Session,逻辑如下:
- 客户端使用用户名、密码进行认证
- 服务端验证用户名、密码正确后生成Token返回给客户端
- 客户端保存Token,访问需要认证的接口时在URL参数或HTTP Header中加入Token
- 服务端通过解码Token进行鉴权,返回给客户端需要的数据

基于 Token 的会话管理理⽅方式有效解决了了基于 Session 的会话管理理⽅方式带来的问题。
- 服务端不需要存储和用户鉴权有关的信息,鉴权信息会被加密到Token中,服务端只需要读取Token中包含的鉴权信息即可
- 避免了共享Session导致的不易扩展问题
- 不需要依赖Cookie,有效避免Cookie带来的CSRF攻击问题
- 使用CORS可以快速解决跨域问题
JWT介绍
JWT(JSON Web Token)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC7519)。JWT本身没有定义任何技术实现,它只是定义了一种基于Token的会话管理的规则,涵盖Token需要包含的标准内容和Token的生成过程,特别适用于分布式站点的单点登录(SSO)场景。
一个JWT Token就像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyODAxODcyNzQ4ODMyMzU4NSwiZXhwIjoxNjE0NTQwMjkxLCJpc3MiOiJibHVlYmVsbCJ9.1k_ZrAtYGCeZhK3iupHxP1kgjBJzQTVTtX0izYFx9wU
它是由.分隔的三部分组成,这三部分依次是:
- 头部(Header)
- 负载(Payload)
- 签名(Signature)
头部和负载以JSON形式存在,这就是JWT中的JSON。三部分的内容都分别单独经过了Base64编码,以.拼接成一个JWT Token。

Header
JWT的Header中存储了所使用的加密算法和Token类型。
json
{
"alg": "HS256",
"typ": "JWT"
}
Payload
Payload表示负载,也是一个JSON对象。JWT规定了7个官方字段供选用:
iss(issuer):签发人exp(expiration time):过期时间sub(subject):主题aud(audience):受众nbf(Not Before):生效时间iat(Issued At):签发时间jti(JWT ID):编号
除了官方字段,开发者也可以自己指定字段和内容,例如:
json
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
注意:JWT默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个JSON对象也要使用Base64URL算法转成字符串。
Signature
Signature部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是HMAC SHA256),按照下面的公式产生签名:
scss
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
JWT优缺点
JWT拥有基于Token的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用Cookie的浏览器环境中正常运行。
而JWT的最大优势是服务端不再需要存储Session,使得服务端认证鉴权业务可以方便扩展,避免存储Session所需要引入的Redis等组件,降低了系统架构复杂度。
但这也是JWT最大的劣势,由于有效期存储在Token中,JWT Token一旦签发,就会在有效期内一直可用,无法在服务端废止。当用户进行登出操作,只能依赖客户端删除掉本地存储的JWT Token,如果需要禁用用户,单纯使用JWT就无法做到了。
基于JWT实现认证实践
Access Token 与 Refresh Token
前面讲的Token,都是Access Token,也就是访问资源接口时所需要的Token。还有另外一种Token,Refresh Token。通常情况下,Refresh Token的有效期会比较长,而Access Token的有效期比较短。当Access Token由于过期而失效时,使用Refresh Token就可以获取到新的Access Token,如果Refresh Token也失效了,用户就只能重新登录了。
改进后的会话管理流程
在JWT的实践中,引入Refresh Token,将会话管理流程改进如下:
- 客户端使用用户名密码进行认证
- 服务端生成有效时间较短的Access Token(例如10分钟),和有效时间较长的Refresh Token(例如7天)
- 客户端访问需要认证的接口时,携带Access Token
- 如果Access Token没有过期,服务端鉴权后返回给客户端需要的数据
- 如果携带Access Token访问需要认证的接口时鉴权失败(例如返回401错误),则客户端使用Refresh Token向刷新接口申请新的Access Token
- 如果Refresh Token没有过期,服务端向客户端下发新的Access Token
- 客户端使用新的Access Token访问需要认证的接口

后端需要对外提供一个刷新Token的接口,前端需要实现一个当Access Token过期时自动请求刷新Token接口获取新Access Token的拦截器。
Gin框架使用JWT
JWT-Go库的基本使用详见:李文周博客 - JWT in Gin
鉴权中间件开发
go
const (
ContextUserIDKey = "userID"
)
var (
ErrorUserNotLogin = errors.New("当前⽤用户未登录")
)
// JWTAuthMiddleware 基于JWT的认证中间件
func JWTAuthMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
// 客户端携带Token有三种⽅方式 1.放在请求头 2.放在请求体 3.放在URI
// 这⾥里里假设Token放在Header的中
// 这⾥里里的具体实现⽅方式要依据你的实际业务情况决定
authHeader := c.Request.Header.Get("Auth")
if authHeader == "" {
ResponseErrorWithMsg(c, CodeInvalidToken,
c.Abort()
"请求头缺少Auth Token")
return
}
mc, err := utils.ParseToken(authHeader)
if err != nil {
ResponseError(c, CodeInvalidToken)
c.Abort()
return
}
// 将当前请求的username信息保存到请求的上下⽂文c上
c.Set(ContextUserIDKey, mc.UserID)
c.Next() // 后续的处理理函数可以⽤用过c.Get("userID")来获取当前请求的⽤用户信息
}
}
生成access token和refresh token
go
// GenToken ⽣成access token 和 refresh token
func GenToken(userID int64) (aToken, rToken string, err error) {
// 创建⼀个我们⾃己的声明
c := MyClaims{
userID, // 自定义字段
jwt.StandardClaims{ ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(), // 过期时间
Issuer: "bluebell", // 签发⼈人
},
}
// 加密并获得完整的编码后的字符串串token
aToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256,c).SignedString(mySecret)
// refresh token 不不需要存任何⾃自定义数据
rToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Second * 30).Unix(), // 过期时间
Issuer: "bluebell", // 签发⼈
}).SignedString(mySecret)
// 使⽤用指定的secret签名并获得完整的编码后的字符串串token
return
}
解析access token
go
// ParseToken 解析JWT
func ParseToken(tokenString string) (claims *MyClaims, err error) {
// 解析token
var token *jwt.Token
claims = new(MyClaims)
token, err = jwt.ParseWithClaims(tokenString, claims, keyFunc)
if err != nil {
return
}
return
if !token.Valid { // 校验token
err = errors.New("invalid token")
}
}
refresh token
go
// RefreshToken 刷新AccessToken
func RefreshToken(aToken, rToken string) (newAToken, newRToken string, err error) {
// refresh token⽆无效直接返回
if _, err = jwt.Parse(rToken, keyFunc); err != nil {
return
}
// 从旧access token中解析出claims数据
var claims MyClaims
_, err = jwt.ParseWithClaims(aToken, &claims, keyFunc)
v,_ := err.(*jwt.ValidationError)
// 当access token是过期错误 并且 refresh token没有过期时就创建⼀一个新的access token
if v.Errors == jwt.ValidationErrorExpired {
return GenToken(claims.UserID)
}
return
}
参考链接: