一、前言

1.1 需要对交易订单加锁原因

开始本篇文章分享之前,先简单进行一下项目描述。该项目为一个中心化钱包。java接收到用户的以太坊转账请求后,调用后端golang服务的转账接口,将交易发送至链上。

如果golang服务处理交易后,正常返回交易哈希至java,说明该交易已发送至链上,后续检查交易哈希是否上链即可。如果因为网络等原因,java未收到golang返回的交易哈希,则认为该交易出现问题,java应将该交易置为待处理状态,java不应该继续发送该订单交易,而等待人工介入,排查具体原因。从而防止用户双花。

在与java层同事沟通以上规则后,java层认为:交易失败后,不会进行重试,但如果代码错误导致bug出现或者交易出现并发的情况下(发送交易时应使用队列,不然一定会出现交易并发情况),可能会进行多笔同样订单交易发送,所以需要在后端golang这里进行加锁,进行最后一次拦截。

1.2 加锁方案

  1. levelDB或mysql 持久化存储

因为golang支持使用levelDB进行key值存储,所以可将java的交易订单利用levelDB进行持久化存储。已经接收的交易订单不再进行二次处理,如果需要重新发起这笔交易,由java改变交易订单后,重新发送。

因为后面考虑golang服务需要部署多节点负载,多节点的levelDB的存储可以使用共享存储,但levelDB的特点是一次只允许一个进程访问一个特定的数据库。所以不能作为分布式存储

  1. redis

使用redis存储key值,实现简单的分布式锁。使用此种方式的缺点是:

  • key值存在过期时间,如果key值失效后,java层依然进行交易重试,则依然会出现双花现象,所以必须要在key值失效前,人工介入排查处理。
  • redis库如果被Flush,则key值不存在,问题交易会被放通
  • redis服务宕机,无法获取key值,同样也会出现交易双花问题

二、Go + Redis 实现分布式锁

2.1 为什么需要分布式锁

  1. 用户下单

锁住 uid,防止重复下单。

  1. 库存扣减

锁住库存,防止超卖。

  1. 余额扣减

锁住账户,防止并发操作。

分布式系统中共享同一个资源时往往需要分布式锁来保证变更资源一致性。

2.2 分布式锁需要具备特性

  1. 排他性

锁的基本特性,并且只能被第一个持有者持有。

  1. 防死锁

高并发场景下临界资源一旦发生死锁非常难以排查,通常可以通过设置超时时间到期自动释放锁来规避。

3.可重入

锁持有者支持可重入,防止锁持有者再次重入时锁被超时释放。

4.高性能高可用

锁是代码运行的关键前置节点,一旦不可用则业务直接就报故障了。高并发场景下,高性能高可用是基本要求。

2.3 实现 Redis 锁应先掌握哪些知识点

  1. set 命令
SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
  • XX :只在键已经存在时,才对键进行设置操作。

先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。

  1. setnx 方法

可以先来看一下setnx方法:

func (c *cmdable) SetNX(key string, value interface{}, expiration time.Duration) *BoolCmd {
	var cmd *BoolCmd
	if expiration == 0 {
		// Use old `SETNX` to support old Redis versions.
		cmd = NewBoolCmd("setnx", key, value)
	} else {
		if usePrecise(expiration) {
			cmd = NewBoolCmd("set", key, value, "px", formatMs(expiration), "nx")
		} else {
			cmd = NewBoolCmd("set", key, value, "ex", formatSec(expiration), "nx")
		}
	}
	c.process(cmd)
	return cmd
}

setnx的含义就是SET if Not Exists,该方法是原子的。如果key不存在,则设置当前key为value成功,返回1;如果当前key已经存在,则设置当前key失败,返回0。

expire(key, seconds)
expire设置过期时间,要注意的是setnx命令不能设置key的超时时间,只能通过expire()来对key设置。

2.4 golang 连接redis

  • 下载reids包
go get github.com/go-redis/redis
  • golang连接redis
package redis

import (
	"github.com/go-redis/redis"
	"wallet/config"
	"wallet/pkg/logger"
)

// RedisDB Redis的DB对象
var RedisDB *redis.Client

func NewRedis() {  //创建redis连接
	RedisDB = redis.NewClient(&redis.Options{
		Addr:     config.Conf.Redis.Host,   //redis连接地址
		Password: config.Conf.Redis.Password,  //redis连接密码
		DB:       config.Conf.Redis.Database,  //redis连接库
	})

	defer func() {
		if r := recover(); r != nil {
			logger.Error("Redis connection error,", r)
		}
	}()
	_, err := RedisDB.Ping().Result()
	if err != nil {
		panic(err)
	}
	logger.Info("Redis connection successful!")
}
  • 项目启动时,应首先创建一个redis连接
func main() {
	//连接redis
	redis.NewRedis()
}

2.5 golang + redis实现分布式锁

  1. 利用redis的Set方法进行存key,简单实现一个加锁的方法
//判断当前订单是否已进行处理
isExist := redis.RedisDB.Exists(order)
//判断是否获取到key值,若获取到,则说明该交易订单已请求,向调用者返回报错
if isExist.Val() != 0 {
	apicommon.ReturnErrorResponse(ctx, 1, "Order transaction already exists, please check transaction", "")
	return
} else { //若未获取到,则说明暂未处理此笔交易订单,向redis中set此订单
	redis.RedisDB.Set(order, order, 86400*time.Second)
}
  1. 利用redis的setnx方法,实现分布式加锁
//判断当前订单是否已进行处理
bool := redis.RedisDB.SetNX(order, order, 24*time.Hour)
if bool.Val() { //SetNX只进行一次操作,若返回为true,则之前未处理该订单,此次已set该key
	logger.Info("The transaction order key value has been saved")
} else { //若返回false,则说明该交易订单已请求,向调用者返回报错
	logger.Error("The transaction order key value has been saved")
	apicommon.ReturnErrorResponse(ctx, 1, "Order transaction already exists, please check transaction", "")
	return
}

2.6 总结

通过代码和执行结果可以看到,我们远程调用 setnx 实际上和单机的 trylock 非常相似,如果获取锁失败,那么相关的任务逻辑就不应该继续向前执行。

setnx 很适合在高并发场景下,用来争抢一些“唯一”的资源。比如交易撮合系统中卖家发起订单,而多个买家会对其进行并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不管是用户设备的时间,还是分布式场景下的各台机器的时间,都是没有办法在合并后保证正确的时序的。哪怕是我们同一个机房的集群,不同的机器的系统时间可能也会有细微的差别。

所以,我们需要依赖于这些请求到达 redis 节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。

参考:

  • golang操作redis官方文档:https://pkg.go.dev/github.com/go-redis/redis#Client.Expire
  • golang分布式锁: https://books.studygolang.com/advanced-go-programming-book/ch6-cloud/ch6-01-lock.html
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐