这篇文章来源于工作中发现的一个项目bug。

  1、项目背景: 这是一个rpc服务,维护的是公司所有用户的基本信息,包括用户注册、修改、注销以及查询,简单而言,本质上是一个 所有用户基本信息的增删改查。用户的有些信息是全局唯一的,比如uid,比如手机号或者用户名,这些信息和uid是一一对应的。那么在修改一 个用户的这些唯一信息时,为了防止多个用户修改成同一个手机号,在更新接口的内部实现时,会先对这个手机号进行加锁,加锁成功后,再根据 手机号去查一次是否已经被某个uid所绑定,如果没有则可以成功修改,最后释放锁;否则抛出修改失败的异常。

  2、现象: 前段时间分析历史存量数据发现,存在两个用户绑定在了同一个手机号上面的现象,且注册时间是一样的。也就是说两个uid在更新同一个手机号的时候,都成功了。

  3、问题: 加锁。现有的实现是类似于Redis的常规分布式锁的方式:setnx expire的组合,如果这个key(手机号)已经被占用了,则返回 的是false,否则返回true,并带有过期时间。实际上我们用的是公司提供的一个cache,在memcache外面封装了一层,不过原理是一样的,这 里不讨论这种分布式锁的可能问题。

  4、分析: 研究我们上面的流程,梳理了一下之前的老代码,发现问题在于,调用一次缓存服务来加锁,是一个rpc,可能发生各种异常,比如超时,但是老代码中并未 考虑这种异常,在catch这个异常内部,什么都没有做,继续向下执行了,类似代码如下:

    boolean locked = true; // default to true
    try {    
        locked = lock.tryLock();
        } catch (Exception e) { /*  */ }
    
    update(long uid, String mobile);
    ...

  5、复现: 排查到这个问题以后,想着怎么复现这种case,发现调一次缓存服务出异常可能不容易模拟,于是就在try内部随机去抛出一些异常,比如parseInt(“ss”)。 在本地或者服务器上模拟两个线程同时修改不同用户,修改为同一个手机号,这里用了一下CyclicBarrier实现的,以实现真正的并发。类似代码如下:


    String mobile = "98799898999";
    CyclicBarrier cyclicBarrier = new CyclicBarrier(2);
    UpdateTask task1 = new UpdateTask(cyclicBarrier, 65022253922305L, mobile);
    UpdateTask task2 = new UpdateTask(cyclicBarrier, 65022253903105L, mobile);
    Thread first = new Thread(task1);
    Thread second = new Thread(task2);
    first.start();
    second.start();
    
    private static class UpdateTask implements Runnable {

        private CyclicBarrier cyclicBarrier;
        private long userId;
        private String mobile;
        
        public UpdateTask(long userId, String mobile) {
            this.userId = userId;
            this.mobile = mobile;
        }

        public UpdateTask(CyclicBarrier cyclicBarrier, long userId, String mobile) {
            this.cyclicBarrier = cyclicBarrier;
            this.userId = userId;
            this.mobile = mobile;
        }

        @Override
        public void run() {

            User user = new User(userId);
            user.getBasicInfo().setVerifiedMobile(mobile);
            try {
                cyclicBarrier.await();
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
            try {
                userService.update(user);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

  CyclicBarrier是一个同步计数器,它允许一些线程一直在某个barrier点等待,直到所有的线程都达到了这个barrier点,然后这些线程同时开始执行。即必须等所有线程都到齐以后,才能一起继续执行。 它的构造函数即是需要等到到屏障点的线程数目;同时,它也允许构造函数中传一个Runnable,这个指的是当所有线程都到达barrier点时,最后到达的一个线程先执行这个Runnable任务,等执行完成以后,所有的 线程再同时开始执行。也比较容易理解。

  CyclicBarrier和CountDownLatch的可以实现的功能是类似的:CountDownLatch的构造函数也是接受一个int参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用CountDownLatch的 countDown方法时,N就会减1,CountDownLatch的await方法会阻塞当前线程,直到N变成零。由于countDown方法可以用在任何地方,所以这里说的N个点,可以是N个线程,也可以是1个线程里的N个执行步骤。 用在多个线程时,只需要把这个CountDownLatch的引用传递到线程里即可。

  它们的不同之处在于,CountDownLatch的计数只能使用一次,countDown方法调用多次后计数就减为0了,而CyclicBarrier的计数器可以使用reset()方法重置。所以CyclicBarrier能处理更为复杂的业 务场景。

  6、思考: 我现在将上面提到的bug修复后,在catch块中抛出异常,是否就一定不会出现上面的bug了呢?换句话说,用这种方式的分布式锁,真的就万无一失吗?在阅读《数据密集型应用设计》一书的时候,提到了 这种分布式锁的很多问题,比如:

  • 1) 加锁和释放锁之间的业务逻辑执行时间过长,线程1加锁成功后,业务逻辑还未执行完,锁就过期了,此时线程2就能成功获取到这个锁,那么可能就会出现并发修改。
  • 2) 和上面一样,线程1业务逻辑没执行完,锁过期,线程2刚获取到锁,线程1执行完任务后,把这个锁释放了,此时线程3又能获取到锁了,这样也会导致线程2和线程3并发在修改,出现问题。
  • 3) 这种情况是一种更深入的情况,假如由于网络抖动,线程1的确加锁成功了,但是缓存服务在返回客户端数据包的时候,突然变慢了,导致我们认为是一个超时,则认为加锁失败。以至于无法修改;极端情况下, 缓存服务集群出现异常,那么它的每一次网络回包都非常慢,导致我们一直超时,一直异常,则永远无法更新。

  7、结论: 上面的问题都是肯定会发生的问题,一旦出现,那么就会出一次bug。

  • 1) 对于上面的第一个问题,我们能够做的就是合理评估我们锁的过期时间,尽量保证过期时间足够短的情况下(这样能够并发修改的量更大),加锁和释放锁之间的业务逻辑不要太长。
  • 2) 对于第二个问题,《Redis 深度历险》一书里面提到了一种可能的解决方法:第二章情况是,线程1释放了线程2加上的锁,即把别人的锁给释放了,那么我们可以在加锁的时候set指令每次设置一个随机的value, 尽量不同的线程设置的value不一致,那么在释放锁的时候,即delete之前,首先去get一次,看现在锁的value和之前加锁的value是否一致,一致才进行释放,否则不能释放,返回false。这里的问题是get一次 这个value和delete操作不是一个原子操作,只能使用Lua脚本来处理,因为Lua脚本可以保证连续多个指令的原子性执行。这儿是一个后续需要在项目中尝试的知识点。
  • 3) 对于第三个问题,暂时无解。得继续深入读DDIA这本书。