Redis实战读书笔记--分布式锁

123 阅读5分钟

这个季度给自己定了一个目标要深入的学习redis,首先读的一本书是redis实战,最近会输出一些关于这本书的读书笔记

首先给出一个背景,每个用户有自己的包裹,包裹中存在不同的商品,用户可以把自己包裹中的某个商品放到市场中售卖,买家也可以在市场中选择商品进行购买 这里涉及到几个redis中存储使用的数据结构

  1. 用户信息 哈希表

    key是字符串user + ":" + #{用户Id} value是 name : "张三" funds: 12

image.png

  1. 用户包裹 列表

    key是inventory + ":" + #{用户Id} value是 商品标识

image.png

  1. 市场表 有序集合

    key是market values是 商品Id + "." + #{用户Id} 分值是 商品价格

image.png

设想这样一个场景,用户B从市场中购买某件产品

  • 查询市场中是否有这个产品
  • 查询用户B的钱是否足够
  • 将买家的钱转移到卖家上
  • 将商品放入卖家的包裹中

这个场景存在一个问题当我们购买过程中如果市场中的这个产品已经被购买或者,买家的钱被另外的线程操作了减少导致不够支付这个产品的钱,就会出现问题

下面给出redis实战这本书中的两个解决方案,以及他们的优劣对比

如何使用Watch + 事务来实现乐观锁

// 指定重试时间进行重试
while (System.currentTimeMillis() < end) {
    // 使用watch命令监控market市场表和buyer买家用户信息表
    conn.watch("market:", buyer);

    double price = conn.zscore("market:", item);
    double funds = Double.parseDouble(conn.hget(buyer, "funds"));
    // 如果价格不存在或者买家的钱不够购买该产品就直接返回错误
    if (price != lprice || price > funds) {
        conn.unwatch();
        return false;
    }

    Transaction trans = conn.multi();
    trans.hincrBy(seller, "funds", (int) price);
    trans.hincrBy(buyer, "funds", (int) -price);
    trans.sadd(inventory, itemId);
    trans.zrem("market:", item);
    List<Object> results = trans.exec();
    // 当执行事务的过程中,watch的key发生了变化就返回null,继续循环执行
    if (results == null) {
        continue;
    }
    return true;
}

可以看到watch加事务的模式就是一种乐观锁的机制,在执行事务之前先对几个关注的key进行监听保存它们当时的值,在真正要执行事务的时候对其进行校验,如果变更了,那么不执行该事务。这样配合重试机制,我们就可以保证逻辑的准确性

锁的方式

锁的方式主要是使用setnx命令,该命令的作用是对指定的key设置一个值,如果key已经存在就不设置,如果key不存在就创建并设置对应值。

加锁

public String acquireLock(Jedis conn, String lockName, long acquireTimeout) {
    String identifier = UUID.randomUUID().toString();

    long end = System.currentTimeMillis() + acquireTimeout;
    while (System.currentTimeMillis() < end) {
        // 使用setnx来设置对应的lock如果设置成功返回1,否则睡眠1毫秒再尝试
        if (conn.setnx("lock:" + lockName, identifier) == 1) {
            return identifier;
        }

        try {
            Thread.sleep(1);
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }

    return null;
}

释放锁

public boolean releaseLock(Jedis conn, String lockName, String identifier) {
    trans.del(lockKey);
}

有了这个锁我们就能写下对应的逻辑

String lock = acquireLock(conn, "market:", 10000);
if (lock == null) {
    String lockKey = "lock:" + lockName;
    return false;
}
try {
    double price = conn.zscore("market:", item);
    double funds = Double.parseDouble(conn.hget(buyer, "funds"));
    // 如果价格不存在或者买家的钱不够购买该产品就直接返回错误
    if (price != lprice || price > funds) {
        
        return false;
    }
    
    Transaction trans = conn.multi();
    trans.hincrBy(seller, "funds", (int) price);
    trans.hincrBy(buyer, "funds", (int) -price);
    trans.sadd(inventory, itemId);
    trans.zrem("market:", item);
    List<Object> results = trans.exec();
    return true;
} finally {
    releaseLock(conn, lockName, lock);
}

使用了锁以后就可以不再使用watch,带来的好处就是减少了重试次数,使用watch命令的时候很容易进行重试,并且重试减少每次都响应时间也跟着减少

那能不能再继续优化?

我们每次锁的是整个market,但是买家购买时其实只关心其中的某个商品,是不是可以让锁变的更加细粒度一点只锁某个商品

// 将market变为item(商品名称)
String lock = acquireLock(conn, item, 10000);

新的问题,我们现在的锁还是很简易的锁,有一些异常场景会出现问题,比如程序在执行过程中有其他的线程更新了lock值,我们在释放锁的时候就不能直接使用del操作

while (true) {
    conn.watch(lockKey);
    if (identifier.equals(conn.get(lockKey))) {
        Transaction trans = conn.multi();
        trans.del(lockKey);
        List<Object> results = trans.exec();
        if (results == null) {
            // 执行解锁的过程中lockKey发生了变化就重试
            continue;
        }
        return true;
    }
    // 如果当前锁的值不是线程持有的锁的值就直接break
    conn.unwatch();
    break;
}

这里可以用watch + 事务和重试的方式来解锁,保证了解锁过程中不会释放了非当前线程持有的锁

再继续深入问题,如果线程执行过程中挂掉了,没有来得及解锁,那这个锁是不是一直不会被释放,这是分布式锁常见的一个问题,我们可以通过添加超时时间来解决

修改加锁接口:

public String acquireLock(Jedis conn, String lockName, long acquireTimeout) {
    String identifier = UUID.randomUUID().toString();

    long end = System.currentTimeMillis() + acquireTimeout;
    while (System.currentTimeMillis() < end) {
        // 使用setnx来设置对应的lock如果设置成功返回1,否则睡眠1毫秒再尝试
        if (conn.setnx("lock:" + lockName, identifier) == 1) {
            // 默认10秒的超时时间
            conn.expire("lock:" + lockName, 10000);
            return identifier;
        }

        try {
            Thread.sleep(1);
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }

    return null;
}

这样就可以解决线程挂掉锁不被释放的问题,但是这样还会有一种极端情况,因为setnx和expire是两个分开的命令,如果线程执行完了setnx后挂掉了,还是有不释放锁的风险,我们可以用lua脚本来解决这个问题,但是redis实战第6章还没有讲到lua脚本,并且它给出了另外一种解决方法。获取锁的时候去检查这个锁是否有过期时间,如果没有就设置过期时间继续重试加锁。

public String acquireLock(Jedis conn, String lockName, long acquireTimeout) {
    String identifier = UUID.randomUUID().toString();

    long end = System.currentTimeMillis() + acquireTimeout;
    String newLockName = "lock:" + lockName;
    while (System.currentTimeMillis() < end) {
        // 使用setnx来设置对应的lock如果设置成功返回1,否则睡眠1毫秒再尝试
        if (conn.setnx(newLockNamee, identifier) == 1) {
            // 默认10秒的超时时间
            conn.expire(newLockName, 10000);
            return identifier;
        } else if(!conn.ttl(newLockName)) {
            // 没有成功获取到锁说明其他线程加了锁,而且没有过期时间说明其他线程加过期时间出现问题,这里帮有问题的线程添加上过期时间就可以
            conn.expire(newLockName, 10000);
        }

        try {
            Thread.sleep(1);
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }

    return null;
}

本章内容主要讲了《redis实战》第6章中分布式锁的演进过程,一步步推导出最终的代码