[黑马点评]关于原子性,锁的笔记

        不得不说,黑马点评是一个非常不错的课程,对于线程安全方面的讲解十分详细且明朗,故写下这篇笔记方便复习及帮助后人()


目标

        我们的目标是对于大量对于优惠劵的访问时,要防止超卖问题以及一人多单问题。


单JVM(非集群)

        非集群的话解决方式很简单:

1.超卖问题

        问题出在我们每次操作完查询优惠劵数量时,准备将优惠劵数量减一时,这个间隔出现了大量的其他线程进行同样操作,把优惠劵减到了0以下:

解决方式也很简单:

        在减操作时同时检测当时的数量是否大于0即可:

        boolean success = seckillVoucherService
                .update().setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();
        if(!success){
            return Result.fail("已抢光!");
        }

如果大于0,说明当前的确有剩下,反正说明没有了,就返回报错。

这里利用的是数据库操作的原子性,我们之所以会出错,就是因为我们的查询和操作之间有间隔,但是数据库操作时会加锁,数据库的查询与操作是具有原子性的!

这样就能利用数据库的原子性来帮助我们变相“加锁”

这样的话仍然是并行,效率较高,属于乐观锁

如果采用悲观锁的话,只能像之前用互斥锁那样来手动变成串行,效率较低,不建议使用


2.一人一单

        这里的思路很明确:在购买之前手动查数据库看看是不是已经买过了。

但是同样存在线程问题:你的查询和购买存在间隙,可能你查的时候你还没买,正在买时另一个线程已经买完了提交了,那么你就买了两张

怎么解决呢?手动在查询和购买之间加锁:

    @Transactional
    public Result getResult(Long voucherId) {
        synchronized (userId.toString().intern()) {
            long userId = UserHolder.getUser().getId();
            int cnt = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (cnt > 0) {
                return Result.fail("已经购买过!");
            }

            boolean success = seckillVoucherService
                    .update().setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId)
                    .gt("stock", 0)
                    .update();
            if (!success) {
                return Result.fail("已抢光!");
            }
            long orderId = redisIdGenerator.nextId("order");

            VoucherOrder voucherOrder = new VoucherOrder();
            voucherOrder.setId(orderId);
            voucherOrder.setVoucherId(voucherId);
            voucherOrder.setUserId(userId);
            save(voucherOrder);
            return Result.ok(orderId);
        }
    }

这样子看上去的确防止了这种问题

但是我们对这个方法开启了事务处理:

在执行完这个方法后,

锁被释放,但是事务还没有提交!!!

那么数据库的操作还没有被提交!!!

这个间隙就可能会被其他线程进入,继续操作!!!

那么我们的锁就要包括整个方法,让这个方法从头到尾完全执行完之后才能释放锁!

    synchronized (userId.toString().intern()) {
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.getResult(voucherId);
    }

改成这样即可

这里为什么要手动获取代理对象呢?

原因在于,只有 getResult是被@Transactional代理的,但你直接

return getResult(voucherId)实际上是 returbn this.getResult(voucherId),返回的是直接调用而不是通过代理对象导入(我写过一篇关于代理的文章)

所以要手动从代理处调用

这样即可解决一人一单问题。


集群的问题

1.误删问题

        误删是指当线程A堵塞,导致锁超时自动释放时,线程B开始获取锁进行工作,就在工作中,堵塞的线程A苏醒并完成了工作释放了锁,线程C就会进入与正在工作的线程B竞争,引发安全问题。

        解决方案非常简单:对于每一把锁赋予创建线程的唯一标识,只有具有该标识的线程才能释放该锁:

        


@Component
public class SimpleRedisLock {
    @Autowired
    private RedisTemplate redisTemplate;

    static final String uuid = UUID.randomUUID().toString() + "-";

    public boolean tryLock(String name, Long expireTime) {
        long threadId = Thread.currentThread().getId();
        //threadId区分线程,uuid区分JVM
        String key = "lock:" + name ;
        Boolean b = redisTemplate.opsForValue().setIfAbsent(key, uuid + threadId, expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(b);
    }

    public void unlock(String name) {
        String key = "lock:" + name;
        long threadId = Thread.currentThread().getId();
        String id = (String) redisTemplate.opsForValue().get(key);
        if (id == uuid + threadId) {
            redisTemplate.delete(key);
        }

    }

}

        UUID是static,这样的话同一个JVM的uuid就相同了,uuid用来区分不同JVM,而threadId用来区分不同的线程。

        这样就貌似保证了在集群环境下每把锁只能解锁自己获取的锁。

2.原子性问题

        但是这样还可能有一个问题:

假如一个线程判断完了线程属于自己,正准备释放锁时,被阻塞了(GC等),时间长到了触发了锁的自动释放,就会有另外一个线程获得锁并进入。

        这时假如阻塞线程苏醒,那它就会立刻释放锁:因为之前判断过了:就会放进其他线程造成安全问题。

但实际上这种情况并不会发生(黑马点评例子没举好):

因为我们的锁标识是threadId+uuid,阻塞线程想要释放锁执行的是:

public void unlock(String name) {
        String key = "lock:" + name;
        long threadId = Thread.currentThread().getId();
        String id = (String) redisTemplate.opsForValue().get(key);
        if (id == uuid + threadId) {
            redisTemplate.delete(key);
        }

    }

就算是删也是根据这个锁的唯一标识(threadId + uuid)来删,根本不会误删其它线程的锁.

但是,如果无法保证查询和操作的原子性,就存在安全性问题,还是需要解决,思路也很简单,让数据库帮我们查询和删除,因为数据库操作是有原子性的!

这里我们就需要Redis执行Lua脚本来实现同时查询和删除!

if(redis.call('get',KEYS[1]) == ARGV[1]) then
    return redis.call('del',KEYS[1])
end
return 0

Lua的语法建议学一下

将脚本存储到resources中

修改锁代码:


@Component
public class SimpleRedisLock {
    @Autowired
    private RedisTemplate redisTemplate;

    static final String uuid = UUID.randomUUID().toString() + "-";
    static final DefaultRedisScript<Long> script;
    static {
        script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setLocation(new ClassPathResource("unlock.lua"));
    }

    public boolean tryLock(String name, Long expireTime) {
        long threadId = Thread.currentThread().getId();
        //threadId区分线程,uuid区分JVM
        String key = "lock:" + name ;
        Boolean b = redisTemplate.opsForValue().setIfAbsent(key, uuid + threadId, expireTime, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(b);
    }

    public void unlock(String name) {
        String key = "unlock:" + name ;
        long threadId = Thread.currentThread().getId();
        redisTemplate.execute(script, Collections.singletonList(key),uuid + threadId);
    }

}

即可完美解决原子性问题.


终极解决方案

有些人可能会问了:

        作者作者,你说的Lua脚本实现原子性,什么悲观锁乐观锁确实高大上,但是有没有更简单的方案啊

        有的兄弟有的,这样简单又好用的我们还有 Redisson! 各种锁一键傻瓜式使用,轻松帮你实现各种高大上复杂的锁!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值