本文介绍redis分布式的实现。
问题提出
现场反馈数据丢失问题。经查,是多进程使用文件锁的机制,在分布式环境下失效的原因导致的。当前机制是这样的,进程A获取lock文件的锁,成功后写A文件,不成功则等待;进程B获取lock文件锁,成功则将A文件重命名为B文件,无法获取则延后再尝试。通过重命名的方式,让A程序只写A文件,B程序只写B文件,2个进程使用同一文件锁实现互斥。
但在有多个节点的分布式环境中,并不能保证2个进程看到的都是同一个文件。重命名也不是原子操作,以下程序模拟打开文件并且不断写文件,但不关闭文件。
$ cat write.py
import time
with open('/tmp/foo.txt', 'a') as file:
while True:
file.write('test...\n')
file.flush()
time.sleep(1)
具体测试过程:
1、运行python write.py不断写foo.txt。
2、再重命名:mv foo.txt bar.txt。
3、查看新文件bar.txt,发现不断有数据产生。
查看2个文件的inode:
$ ls -i foo.txt | awk '{print $1}'
71599628
$ mv foo.txt bar.txt
$ ls -i bar.txt | awk '{print $1}'
71599628
可以看到,两者的inode一样。
在 Linux 中,mv 命令仅修改文件名与 inode 的映射,不会影响已打开的文件句柄,因此,在重命名后,测试程序持续新文件写入,破坏了原有的数据逻辑。
解决方法
为此,需要寻求可用于分布式的锁机制,经查,redis是一个相对高效且简单的实现方案。
测试
测试环境
服务端:docker部署redis,单节点。
客户端:linux系统,redis命令行。
关键命令
如下表:
| 操作 | 指令 | 说明 |
|---|---|---|
| 加锁 | SET lock_key unique_value NX PX 30000 |
NX = 仅当键不存在时设置,PX = 过期时间(30s),unique_value = 唯一标识(如 UUID) |
| 解锁 | Lua 脚本(原子判断 + 删除) | 先判断 value 是否为自己的,再删除,避免误解锁 |
| 续期(可选) | 定时刷新锁的过期时间(如每 10s 刷新为 30s) | 防止锁在业务执行完前过期 |
连接:
redis-cli -h 172.17.18.19 -p 6379 -a 123456
选择数据库:
select 1
在一终端模拟程序A获取锁:
set data:lock client_aaa_123 NX PX 20000
在同一终端或在另一终端模拟程序B获取锁:
set data:lock client_bbb_123 NX PX 30000
查看存活时间(秒):
TTL data:lock
查看锁:
get data:lock
安全删除锁(使用Lua脚本确保原子性,即先检查值再删除),程序A:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 data:lock "client_aaa_123"
安全删除锁,程序B:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 data:lock "client_bbb_123"
不正确的删除锁:
del data:lock
判断锁是否存在:
exists data:lock
命令行测试
场景1:程序A先获取锁,30秒后,程序B获取锁。
结论:30秒后,锁被释放,B正常获取锁。
A获取锁成功:
> set data:lock client_aaa_123 NX PX 30000
OK
在30秒内,程序B获取锁失败:
> set data:lock client_bbb_123 NX PX 30000
(nil)
30秒超时后,存活时间为负数,即已超时被释放
> ttl data:lock
(integer) -2
程序B获取锁成功:
> set data:lock client_bbb_123 NX PX 30000
OK
场景2:程序A先获取锁,处理完毕后释放,程序B获取锁
结论:锁被所有者A释放,B正常获取锁。
A获取锁成功:
> set data:lock client_aaa_123 NX PX 30000
OK
在存活期内程序A释放锁:
> ttl data:lock
(integer) 17
> EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 data:lock "client_aaa_123"
(integer) 1
存活时间为负数,即已超时被释放
> ttl data:lock
(integer) -2
程序B获取锁成功:
> set data:lock client_bbb_123 NX PX 30000
OK
golang实现
核心代码
将redis相关操作封装成工具函数,再新加分布式锁功能,代码如下:
package util
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/go-redis/redis/v8"
)
// RedisClient 多DB连接池客户端
type RedisClient struct {
clients map[int]*redis.Client // DB编号到连接的映射
mu sync.RWMutex // 并发控制锁
config *RedisConfig // 基础配置
baseCtx context.Context // 基础上下文
cancel context.CancelFunc // 整体取消函数
lock *RedisLock // 分布式锁配置
}
// 分布式锁结构体
type RedisLock struct {
lockKey string // 锁名
uniqueID string // 唯一标识(防止误解锁)
expireTime int // 锁过期时间,单位为秒
}
// RedisConfig Redis连接配置
type RedisConfig struct {
Addr string // 地址,格式: host:port
Password string // 密码
DB int // 默认数据库编号
PoolSize int // 每个DB的连接池大小
Timeout int
}
// NewRedisClient 创建多DB连接池
func NewRedisClient(config *RedisConfig) (*RedisClient, error) {
if config == nil {
return nil, errors.New("redis config cannot be nil")
}
// 设置默认值
if config.PoolSize <= 0 {
config.PoolSize = 10
}
if config.Timeout <= 0 {
config.Timeout = 3
}
// 创建基础上下文,用于整个客户端的生命周期
ctx, cancel := context.WithCancel(context.Background())
rc := &RedisClient{
clients: make(map[int]*redis.Client),
config: config,
baseCtx: ctx,
cancel: cancel,
}
// 初始化默认DB连接
if _, err := rc.getDBClient(config.DB); err != nil {
return nil, err
}
return rc, nil
}
// getDBClient 获取指定DB的连接(线程安全)
func (rc *RedisClient) getDBClient(db int) (*redis.Client, error) {
// 读锁检查现有连接
rc.mu.RLock()
if client, ok := rc.clients[db]; ok {
rc.mu.RUnlock()
return client, nil
}
rc.mu.RUnlock()
// 写锁创建新连接
rc.mu.Lock()
defer rc.mu.Unlock()
// 双检锁再次检查
if client, ok := rc.clients[db]; ok {
return client, nil
}
dialTimeout := time.Duration(rc.config.Timeout) * time.Second
// 创建新连接
client := redis.NewClient(&redis.Options{
Addr: rc.config.Addr,
Password: rc.config.Password,
DB: db,
PoolSize: rc.config.PoolSize,
DialTimeout: dialTimeout,
})
// 测试连接(指定超时时间)
ctx, cancel := context.WithTimeout(rc.baseCtx, dialTimeout)
defer cancel()
if _, err := client.Ping(ctx).Result(); err != nil {
return nil, fmt.Errorf("failed to connect DB %d: %w", db, err)
}
rc.clients[db] = client
return client, nil
}
// Close 关闭所有DB连接
func (rc *RedisClient) Close() error {
rc.cancel()
rc.mu.Lock()
defer rc.mu.Unlock()
var errs []error
for db, client := range rc.clients {
if err := client.Close(); err != nil {
errs = append(errs, fmt.Errorf("DB %d: %w", db, err))
}
delete(rc.clients, db)
}
if len(errs) > 0 {
return fmt.Errorf("errors closing connections: %v", errs)
}
return nil
}
// HealthCheck 检查所有DB连接状态
func (rc *RedisClient) HealthCheck() map[int]bool {
rc.mu.RLock()
defer rc.mu.RUnlock()
status := make(map[int]bool)
for db, client := range rc.clients {
ctx, cancel := context.WithTimeout(rc.baseCtx, time.Second)
_, err := client.Ping(ctx).Result()
cancel()
status[db] = err == nil
}
return status
}
// --- 基础操作封装(自动使用默认DB)---
func (rc *RedisClient) Set(key string, value interface{}, second int) error {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return err
}
return client.Set(rc.baseCtx, key, value, time.Duration(second)*time.Second).Err()
}
func (rc *RedisClient) Get(key string) (string, error) {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return "", err
}
return client.Get(rc.baseCtx, key).Result()
}
// --- 指定DB操作 ---
func (rc *RedisClient) SetWithDB(db int, key string, value interface{}, second int) error {
client, err := rc.getDBClient(db)
if err != nil {
return err
}
return client.Set(rc.baseCtx, key, value, time.Duration(second)*time.Second).Err()
}
func (rc *RedisClient) GetWithDB(db int, key string) (string, error) {
client, err := rc.getDBClient(db)
if err != nil {
return "", err
}
return client.Get(rc.baseCtx, key).Result()
}
// --- 高级用法 ---
// Do 在指定DB执行自定义操作
func (rc *RedisClient) Do(db int, fn func(*redis.Client) error) error {
client, err := rc.getDBClient(db)
if err != nil {
return err
}
return fn(client)
}
// GetClients 获取当前所有活跃的DB连接(只读)
func (rc *RedisClient) GetClients() map[int]*redis.Client {
rc.mu.RLock()
defer rc.mu.RUnlock()
copy := make(map[int]*redis.Client)
for k, v := range rc.clients {
copy[k] = v
}
return copy
}
// --- 哈希操作 ---
// HSet 设置哈希字段值
func (rc *RedisClient) HSet(key string, values ...interface{}) error {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return err
}
return client.HSet(rc.baseCtx, key, values...).Err()
}
// HGet 获取哈希字段值
func (rc *RedisClient) HGet(key, field string) (string, error) {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return "", err
}
return client.HGet(rc.baseCtx, key, field).Result()
}
// HGetAll 获取所有哈希字段和值
func (rc *RedisClient) HGetAll(key string) (map[string]string, error) {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return map[string]string{}, err
}
return client.HGetAll(rc.baseCtx, key).Result()
}
// --- 其他实用功能 ---
// Ping 测试连接是否正常
func (rc *RedisClient) Ping() (string, error) {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(rc.baseCtx, time.Second)
defer cancel()
return client.Ping(ctx).Result()
}
// TTL 获取键的剩余生存时间
func (rc *RedisClient) TTL(key string) (time.Duration, error) {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return 0, err
}
return client.TTL(rc.baseCtx, key).Result()
}
// Keys 查找所有符合给定模式的键
func (rc *RedisClient) Keys(pattern string) ([]string, error) {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return []string{}, err
}
return client.Keys(rc.baseCtx, pattern).Result()
}
// FlushDB 清空当前数据库
func (rc *RedisClient) FlushDB() error {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return err
}
return client.FlushDB(rc.baseCtx).Err()
}
// FlushAll 清空所有数据库
func (rc *RedisClient) FlushAll() error {
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return err
}
return client.FlushAll(rc.baseCtx).Err()
}
///////////////////////////////
// 新建分布式锁
func (rc *RedisClient) SetRedisLock(lockKey, lockValue string, expireTime int) {
foo := RedisLock{
lockKey: lockKey,
uniqueID: lockValue, // 生成唯一ID
expireTime: expireTime,
}
rc.lock = &foo
}
// Lock 加锁(原子操作)
func (rc *RedisClient) Lock() (bool, error) {
var ok bool = false
if rc.lock == nil {
return ok, fmt.Errorf("锁配置为空")
}
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return ok, err
}
expireTime := time.Duration(rc.lock.expireTime) * time.Second
// SETNX + PX 原子加锁
ok, err = client.SetNX(rc.baseCtx, rc.lock.lockKey, rc.lock.uniqueID, expireTime).Result()
if err != nil {
return ok, fmt.Errorf("加锁失败: %v", err)
}
return ok, nil
}
// UnLock 解锁(原子操作) 注:当TTL过期后,UnLock返回0
func (rc *RedisClient) UnLock() (int64, error) {
var ret int64 = 0
if rc.lock == nil {
return ret, fmt.Errorf("锁配置为空")
}
client, err := rc.getDBClient(rc.config.DB)
if err != nil {
return ret, err
}
// Lua脚本:先判断value是否匹配,再删除
unlockScript := `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`
ret, err = client.Eval(rc.baseCtx, unlockScript, []string{rc.lock.lockKey}, rc.lock.uniqueID).Int64()
if err != nil {
return ret, fmt.Errorf("解锁失败: %v", err)
}
return ret, nil
}
测试
测试代码:
var lockkey string = "data:lock"
var lockvalue string
var ttlTime int = 5 //锁过期时间
func Test(argv []string) (ret string) {
klog.Println("test of redis lock")
if len(argv) < 2 {
klog.Println("需再加一个参数作为value\n")
return
}
lockvalue = argv[1]
rclient, err := InitRedisLock("172.16.9.19:16379", "Gxjky@2020", 1, 3, 30)
if err != nil {
klog.Println(err)
return
}
// 指定key和value,过期时间
klog.Printf("设置锁参数 key: %v value: %v ttl: %v\n", lockkey, lockvalue, ttlTime)
rclient.SetRedisLock(lockkey, lockvalue, ttlTime)
go func() {
for {
ttl, _ := rclient.TTL(lockkey)
value, _ := rclient.Get(lockkey)
klog.Printf("监控 key: %v value: %v ttl: %v\n", lockkey, value, ttl)
com.Sleep(1000)
}
}()
for {
ok, err := rclient.Lock()
if !ok {
value, _ := rclient.Get(lockkey)
klog.Printf("无法获取锁(当前值: %v),等待2秒\n", value)
// com.Sleep(com.GetRandomNum(100, 200))
com.Sleep(2000)
continue
}
klog.Println("成功获取到锁,Lock返回:", ok, err)
s := com.GetRandomNum(1, 4) // 模拟的耗时,要小于TTL,当TTL过期后,UnLock返回0
klog.Printf("假装在处理业务,耗时 %v 秒\n", s)
com.Sleep(s * 1000)
ret, err := rclient.UnLock()
s = com.GetRandomNum(3, 7)
klog.Printf("UnLock返回:%v %v,延时 %v 秒\n", ret, err, s)
com.Sleep(s * 1000)
}
}
func InitRedisLock(addr, password string, db int, timeout, ttl int) (rc *util.RedisClient, err error) {
err = nil
config := &util.RedisConfig{
Addr: addr,
Password: password,
DB: db,
Timeout: timeout,
}
client, err1 := util.NewRedisClient(config)
if err1 != nil {
err = err1
return
}
rc = client
return
}
场景1:程序A获取锁,超时后,才能获取锁
$ ./cmdHub-win.exe -m misc redislock client_aaa_123
[2026-01-30 19:50:53 949] [INFO] test of redis lock
[2026-01-30 19:50:53 958] [INFO] 设置锁参数 key: data:lock value: client_aaa_123 ttl: 6
[2026-01-30 19:50:53 962] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:50:53 962] [INFO] 假装在处理业务,耗时2秒
[2026-01-30 19:50:53 967] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 6s
[2026-01-30 19:50:54 973] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 5s
[2026-01-30 19:50:55 968] [INFO] 无法获取锁,等待2秒
[2026-01-30 19:50:55 979] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 4s
[2026-01-30 19:50:56 986] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 3s
[2026-01-30 19:50:57 978] [INFO] 无法获取锁,等待2秒
[2026-01-30 19:50:57 995] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 2s
[2026-01-30 19:50:59 004] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 1s
[2026-01-30 19:50:59 989] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:50:59 989] [INFO] 假装在处理业务,耗时2秒
[2026-01-30 20:10:00 010] [INFO] 监控 key: data:lock value: client_aaa_123 ttl: 6s
从日志看到,不手动释放锁情况下,需在超时后才能再次获取锁。从监控日志看,TTL不断在倒计时。
场景2:程序A获取锁,观察超时后释放锁的状态
测试代码:
func UnLockAfterTime(rclient *util.RedisClient) {
ok, err := rclient.Lock()
if !ok {
value, _ := rclient.Get(lockkey)
klog.Printf("无法获取锁(当前值: %v),等待2秒\n", value)
return
}
klog.Println("成功获取到锁,Lock返回:", ok, err)
s := com.GetRandomNum(3, 8) // 模拟的耗时,要小于TTL,当TTL过期后,UnLock返回0
klog.Printf("假装在处理业务,耗时 %v 秒\n", s)
com.Sleep(s * 1000)
ret, err := rclient.UnLock()
klog.Printf("UnLock返回:%v %v\n", ret, err)
}
输出日志:
[2026-01-30 20:14:13 304] [INFO] test of redis lock
[2026-01-30 20:14:13 313] [INFO] 设置锁参数 key: data:lock value: client_aaa_123 ttl: 5
[2026-01-30 20:14:13 317] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 20:14:13 317] [INFO] 假装在处理业务,耗时 3 秒
[2026-01-30 20:14:16 321] [INFO] UnLock返回:1 <nil>
[2026-01-30 20:14:18 332] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 20:14:18 332] [INFO] 假装在处理业务,耗时 3 秒
[2026-01-30 20:14:21 338] [INFO] UnLock返回:1 <nil>
[2026-01-30 20:14:23 340] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 20:14:23 341] [INFO] 假装在处理业务,耗时 8 秒
[2026-01-30 20:14:31 344] [INFO] UnLock返回:0 <nil>
[2026-01-30 20:14:33 347] [INFO] 成功获取到锁,Lock返回: true <nil>
场景3:程序A、B同时同时执行,观察2者获取锁的过程
先结合日志分析锁占用释放过程:
- 19:30:30 908:程序A获取到锁后,业务处理完毕,释放锁。延时7秒再开始下一轮处理。
- 19:30:37 349:程序B执行,获取到锁(比程序A提前约600毫秒),占用3秒。
- 19:30:37 920:紧接着,程序A执行。此时redis对应value为
client_bbb_123,说明B在占用,还没释放,无法获取到锁,符合预期。 - 19:30:40 353:程序B处理3秒时间到,释放锁。延时4秒再开始下一轮处理。
- 19:30:41 928:程序A执行(B还在休息),获取到锁。业务耗时3秒。
- 19:30:44 359:程序B执行,间隔不到3秒,此时redis对应value为
client_aaa_123,说明A在占用,还没释放。
程序A日志:
./cmdHub-win.exe -m misc redislock client_aaa_123
[2026-01-30 19:30:28 892] [INFO] test of redis lock
[2026-01-30 19:30:28 901] [INFO] 设置锁参数 key: data:lock value: client_aaa_123 ttl: 6
[2026-01-30 19:30:28 904] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:28 904] [INFO] 假装在处理业务,耗时 2 秒
[2026-01-30 19:30:30 908] [INFO] UnLock返回:1 <nil>,延时 7 秒
[2026-01-30 19:30:37 920] [INFO] 无法获取锁(当前值: client_bbb_123),等待2秒
[2026-01-30 19:30:39 926] [INFO] 无法获取锁(当前值: client_bbb_123),等待2秒
[2026-01-30 19:30:41 928] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:41 928] [INFO] 假装在处理业务,耗时 3 秒
[2026-01-30 19:30:44 932] [INFO] UnLock返回:1 <nil>,延时 6 秒
[2026-01-30 19:30:50 934] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:50 935] [INFO] 假装在处理业务,耗时 2 秒
[2026-01-30 19:30:52 946] [INFO] UnLock返回:1 <nil>,延时 6 秒
[2026-01-30 19:30:58 949] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:58 949] [INFO] 假装在处理业务,耗时 1 秒
[2026-01-30 19:30:59 952] [INFO] UnLock返回:1 <nil>,延时 5 秒
程序B日志:
$ ./cmdHub-win.exe -m misc redislock client_bbb_123
[2026-01-30 19:30:32 321] [INFO] test of redis lock
[2026-01-30 19:30:32 330] [INFO] 设置锁参数 key: data:lock value: client_bbb_123 ttl: 6
[2026-01-30 19:30:32 332] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:32 333] [INFO] 假装在处理业务,耗时 1 秒
[2026-01-30 19:30:33 337] [INFO] UnLock返回:1 <nil>,延时 4 秒
[2026-01-30 19:30:37 349] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:37 349] [INFO] 假装在处理业务,耗时 3 秒
[2026-01-30 19:30:40 353] [INFO] UnLock返回:1 <nil>,延时 4 秒
[2026-01-30 19:30:44 359] [INFO] 无法获取锁(当前值: client_aaa_123),等待2秒
[2026-01-30 19:30:46 363] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:46 363] [INFO] 假装在处理业务,耗时 3 秒
[2026-01-30 19:30:49 367] [INFO] UnLock返回:1 <nil>,延时 3 秒
[2026-01-30 19:30:52 373] [INFO] 无法获取锁(当前值: client_aaa_123),等待2秒
[2026-01-30 19:30:54 377] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:30:54 377] [INFO] 假装在处理业务,耗时 2 秒
[2026-01-30 19:30:56 382] [INFO] UnLock返回:1 <nil>,延时 3 秒
[2026-01-30 19:30:59 387] [INFO] 无法获取锁(当前值: client_aaa_123),等待2秒
[2026-01-30 19:31:01 391] [INFO] 成功获取到锁,Lock返回: true <nil>
[2026-01-30 19:31:01 391] [INFO] 假装在处理业务,耗时 3 秒
小结
从模拟测试结果可知,使用redis分布式锁,能让不同的客户端程序实现互斥访问,各客户端使用不同的value,可以用程序名称+程序启动时间戳,也可再加上IP和版本号,从而实现自己的锁自己解,加了超时时间,能够避免死锁的情况产生。