开始本篇文章分享之前,先简单进行一下项目描述。该项目为一个中心化钱包。java接收到用户的以太坊转账请求后,调用后端golang服务的转账接口,将交易发送至链上。
如果golang服务处理交易后,正常返回交易哈希至java,说明该交易已发送至链上,后续检查交易哈希是否上链即可。如果因为网络等原因,java未收到golang返回的交易哈希,则认为该交易出现问题,java应将该交易置为待处理状态,java不应该继续发送该订单交易,而等待人工介入,排查具体原因。从而防止用户双花。
在与java层同事沟通以上规则后,java层认为:交易失败后,不会进行重试,但如果代码错误导致bug出现或者交易出现并发的情况下(发送交易时应使用队列,不然一定会出现交易并发情况),可能会进行多笔同样订单交易发送,所以需要在后端golang这里进行加锁,进行最后一次拦截。
因为golang支持使用levelDB进行key值存储,所以可将java的交易订单利用levelDB进行持久化存储。已经接收的交易订单不再进行二次处理,如果需要重新发起这笔交易,由java改变交易订单后,重新发送。
因为后面考虑golang服务需要部署多节点负载,多节点的levelDB的存储可以使用共享存储,但levelDB的特点是一次只允许一个进程访问一个特定的数据库。所以不能作为分布式存储。
使用redis存储key值,实现简单的分布式锁。使用此种方式的缺点是:
锁住 uid,防止重复下单。
锁住库存,防止超卖。
锁住账户,防止并发操作。
分布式系统中共享同一个资源时往往需要分布式锁来保证变更资源一致性。
锁的基本特性,并且只能被第一个持有者持有。
高并发场景下临界资源一旦发生死锁非常难以排查,通常可以通过设置超时时间到期自动释放锁来规避。
3.可重入
锁持有者支持可重入,防止锁持有者再次重入时锁被超时释放。
4.高性能高可用
锁是代码运行的关键前置节点,一旦不可用则业务直接就报故障了。高并发场景下,高性能高可用是基本要求。
SET key value [EX seconds] [PX milliseconds] [NX|XX]
先用setnx来抢锁,如果抢到之后,再用expire给锁设置一个过期时间,防止锁忘记了释放。
可以先来看一下setnx方法:
unc (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设置。
go get github.com/go-redis/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!")
}
func main() {
//连接redis
redis.NewRedis()
}
//判断当前订单是否已进行处理
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)
}
//判断当前订单是否已进行处理
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
}
通过代码和执行结果可以看到,我们远程调用 setnx 实际上和单机的 trylock 非常相似,如果获取锁失败,那么相关的任务逻辑就不应该继续向前执行。
setnx 很适合在高并发场景下,用来争抢一些“唯一”的资源。比如交易撮合系统中卖家发起订单,而多个买家会对其进行并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不管是用户设备的时间,还是分布式场景下的各台机器的时间,都是没有办法在合并后保证正确的时序的。哪怕是我们同一个机房的集群,不同的机器的系统时间可能也会有细微的差别。
所以,我们需要依赖于这些请求到达 redis 节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。
参考: