Zookeeper实现分布式锁

分布式锁的几种实现方式:

  目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

  在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。

  针对分布式锁的实现,目前比较常用的有以下几种方案:

  基于数据库实现分布式锁 基于缓存(redis,memcached,tair)实现分布式锁 基于Zookeeper实现分布式锁

  现在模拟一个使用Zookeeper实现分布式锁,假设有A,B,C三台客户端去访问资源,调用zookeeper获得锁。客户端三个在zookeeper的 /locks节点下创建一个/lock节点,由于节点是唯一性的特性,只有一个人会创建成功,其余两个创建失败,会进入监听/locks节点的变化,如果/locks下子节点/lock节点发生变化,其余两个可以去拿锁,这样是否好呢? 这样子会导致惊群效应。就是一个触发使得在短时间呢会触发大量的watcher事件,但是只有一个客户端能拿到锁。所以这种方式不建议。

基于Zookeeper 的API:

  有一种比较好的方法就是利用 zookeeper 的有序节点的特性,基本思路:

  1. 在获取分布式锁的时候在locker节点下创建临时顺序节点,释放锁的时候删除该临时节点。
  2. 客户端调用createNode方法在locks下创建临时顺序节点,然后调用getChildren(“locks”)来获取locks下面的所有子节点,注意此时不用设置任何Watcher。
  3. 客户端获取到所有的子节点path之后,如果发现自己创建的子节点序号最小,那么就认为该客户端获取到了锁。
  4. 如果发现自己创建的节点并非locks所有子节点中最小的,说明自己还没有获取到锁,此时客户端需要找到比自己小的那个节点,然后对其调用exist()方法,同时对其注册事件监听器。
  5. 之后,让这个被关注的节点删除,则客户端的Watcher会收到相应通知,此时再次判断自己创建的节点是否是locks子节点中序号最小的,如果是则获取到了锁,如果不是则重复以上步骤继续获取到比自己小的一个节点并注册监听。

   下面看一下我的代码实现:

public class DistributedLock implements Lock, Watcher {

    private ZooKeeper zk = null;
    private String ROOT_LOCK = "/locks"; //定义根节点
    private String WAIT_LOCK; //等待前一个锁
    private String CURRENT_LOCK; //表示当前的锁
    // 作为监听前继节点阻塞
    private CountDownLatch countDownLatch; //
    //作为连接阻塞,等待成功连接事件到达才放下门闩
    private CountDownLatch connectCountDownLatch =new CountDownLatch(1);

    public DistributedLock() {

        try {
            zk = new ZooKeeper("192.168.1.101:2181",
                    10000, this);
            connectCountDownLatch.await();
            //判断根节点是否存在
            Stat stat = zk.exists(ROOT_LOCK, true);
            if (stat == null) {//如果不存在创建
                zk.create(ROOT_LOCK, "0".getBytes(),
                        ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }

    }

    /**
     * 尝试获取锁
     */
    @Override
    public boolean tryLock() {

        try {
            //创建临时有序节点
            CURRENT_LOCK = zk.create(ROOT_LOCK + "/", "0".getBytes(),
                    ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            System.out.println(Thread.currentThread().getName() + "->" +
                    CURRENT_LOCK + ",尝试竞争锁");
            List<String> childrens = zk.getChildren(ROOT_LOCK, false); //获取根节点下的所有子节点
            SortedSet<String> sortedSet = new TreeSet();//定义一个集合进行排序
            for (String children : childrens) { // 排序
                sortedSet.add(ROOT_LOCK + "/" + children);
            }
            String firstNode = sortedSet.first(); //获得当前所有子节点中最小的节点
            // 取出比我创建的节点还小的节点,没有的话为null
            SortedSet<String> lessThenMe = ((TreeSet<String>) sortedSet).headSet(CURRENT_LOCK);
            if (CURRENT_LOCK.equals(firstNode)) {//通过当前的节点和子节点中最小的节点进行比较,如果相等,表示获得锁成功
                return true;
            }
            if (!lessThenMe.isEmpty()) {
                WAIT_LOCK = lessThenMe.last();//获得比当前节点更小的最后一个节点,设置给WAIT_LOCK
            }
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }


    @Override
    public void lock() {
        if (this.tryLock()) { //如果获得锁成功
            System.out.println(Thread.currentThread().getName() + "->" + CURRENT_LOCK + "->获得锁成功");
            return;
        }
        try {
            waitForLock(WAIT_LOCK); //没有获得锁,继续等待获得锁
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private boolean waitForLock(String prev) throws KeeperException, InterruptedException {
        //监听当前节点的上一个节点 注册事件,这里需要在默认的 watch 事件里面处理
        // 这里是我们之前提到的 watch 事件触发最后执行的 process 回调里面的 请看最下行代码
        Stat stat = zk.exists(prev, true);
        if (stat != null) {
            System.out.println(Thread.currentThread().getName() + "->等待锁" + ROOT_LOCK + "/" + prev + "释放");
            countDownLatch = new CountDownLatch(1);
            countDownLatch.await();// 进入等待,这里需要
            // watcher触发以后,还需要再次判断当前等待的节点是不是最小的
            System.out.println(Thread.currentThread().getName() + "->获得锁成功");
        }
        return true;
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        System.out.println(Thread.currentThread().getName() + "->释放锁" + CURRENT_LOCK);
        try {
            // -1 表示无论如何先把这个节点删了再说
            zk.delete(CURRENT_LOCK, -1);
            CURRENT_LOCK = null;
            zk.close();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }

    @Override
    public void process(WatchedEvent event) {
        if(Event.KeeperState.SyncConnected==event.getState()){
            //如果收到了服务端的响应事件,连接成功
            connectCountDownLatch.countDown();
        }
        // 事件回调 countDownLatch.countDown();
        if (this.countDownLatch != null) {
            this.countDownLatch.countDown();
        }
    }
}

  代码中实现了  Lock,Watcher 两个接口。主要用到的是lock 里面的trylock方法,尝试去获取锁。然后还有watcher里面的处理回调的方法

测试代码:

 public static void main( String[] args ) throws IOException {
        CountDownLatch countDownLatch=new CountDownLatch(10);
        for(int i=0;i<10;i++){
            new Thread(()->{
                try {
                    countDownLatch.await();
                    DistributedLock distributedLock=new DistributedLock();
                    distributedLock.lock(); //获得锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            },"Thread-"+i).start();
            countDownLatch.countDown();
        }
        System.in.read();
    }

  运行结果就可以看到:

Thread-8->/locks/0000000040,尝试竞争锁
Thread-5->/locks/0000000044,尝试竞争锁
Thread-9->/locks/0000000041,尝试竞争锁
Thread-3->/locks/0000000042,尝试竞争锁
Thread-7->/locks/0000000046,尝试竞争锁
Thread-1->/locks/0000000043,尝试竞争锁
Thread-0->/locks/0000000047,尝试竞争锁
Thread-4->/locks/0000000045,尝试竞争锁
Thread-2->/locks/0000000049,尝试竞争锁
Thread-6->/locks/0000000048,尝试竞争锁
Thread-8->/locks/0000000040->获得锁成功
Thread-9->等待锁/locks//locks/0000000040释放
Thread-5->等待锁/locks//locks/0000000043释放
Thread-0->等待锁/locks//locks/0000000046释放
Thread-1->等待锁/locks//locks/0000000042释放
Thread-2->等待锁/locks//locks/0000000048释放
Thread-7->等待锁/locks//locks/0000000045释放
Thread-6->等待锁/locks//locks/0000000047释放
Thread-3->等待锁/locks//locks/0000000041释放
Thread-4->等待锁/locks//locks/0000000044释放

  这样子当我们把 Thread-8所锁的节点 0000000040 删掉   Thread-9 就会获取锁,以此类推。实现分布式锁

基于新版 Curator 封装集成:

  curator对于锁这块做了一些封装,curator提供了InterProcessMutex 这样一个api。除了分布式锁之 外,还提供了leader选举、分布式队列等常用的功能。

  • InterProcessMutex:分布式可重入排它锁
  • InterProcessSemaphoreMutex:分布式排它锁
  • InterProcessReadWriteLock:分布式读写锁

  这里演示一个扣减库存的场景。

1. 引入 pom :

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.2.0</version>
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.2.0</version>
</dependency>

2.配置 CuratorFramework:

@Configuration
public class CuratorConfig {

    @Bean
    public CuratorFramework curatorFramework(){
        CuratorFramework curatorFramework=CuratorFrameworkFactory
                .builder()
                .connectString("192.168.1.101:2181")
                .sessionTimeoutMs(15000)
                .connectionTimeoutMs(20000)
                .retryPolicy(new ExponentialBackoffRetry(1000,10))
                .build();
        curatorFramework.start();
        return curatorFramework;
    }
}

3. 关于mybatis、mysql相关配置代码就不上了,其中主要的就是一下的核心代码:

@RestController
public class GoodsStockController {


    @Autowired
    IGoodsStockService goodsStockService;

    @Autowired
    CuratorFramework curatorFramework;

    @GetMapping(path = "/goods-stock")
    public String purchase(Integer goodsNo) throws Exception {
        QueryWrapper<GoodsStock> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("goods_no", goodsNo);
        //基于临时有序节点来实现的分布式锁.
        InterProcessMutex lock = new InterProcessMutex(curatorFramework, "/Locks");
        try {
            //抢占分布式锁资源(阻塞的)
            lock.acquire();
            //先查询数据库
            GoodsStock goodsStock = goodsStockService.getOne(queryWrapper);
            Thread.sleep(new Random().nextInt(1000));
            if (goodsStock == null) {
                return "指定商品不存在";
            }
            if (goodsStock.getStock().intValue() < 1) {
                return "库存不够";
            }
            goodsStock.setStock(goodsStock.getStock() - 1);
            //库存-1操作
            boolean res = goodsStockService.updateById(goodsStock);
            if (res) {
                return "抢购书籍:" + goodsNo + "成功";
            }
        } finally {
            if (lock.isOwnedByCurrentThread()) {
                lock.release(); //释放锁
            }
        }
        return "抢购失败";
    }
}

3. 修改数据库库存字段,通过 jmeter 压测会看到 zookeeper 的节点变化。得到分布式环境下扣减库存的正确姿势。

Curator实现分布式锁的源码分析:

  Curator 的分布式锁的实现思想跟 Zookeeper 原生Api 的实现是大同小异的 。

抢占锁的逻辑:Curator构造函数:

InterProcessMutex(CuratorFramework client, String path, String lockName, int maxLeases, LockInternalsDriver driver)
    {
    // maxLeases=1,表示可以获得分布式锁的线程数量(跨JVM)为1,即为互斥锁
    // 锁节点的名称前缀,lock-0000001, 后面部分是有序递增的序列号
    basePath = PathUtils.validatePath(path);
    // internals的类型为LockInternals,InterProcessMutex将分布式锁的申请和释放操作委托给internals执行 new StandardLockInternalsDriver()
    internals = new LockInternals(client, driver, path, lockName, maxLeases);
}

  acquire方法:调用acquire方法,该方法有两个重载方法,另外一个是带超时时间,当等待超时没有获得锁则放弃锁的占用。

@Override
public void acquire() throws Exception
    { //无限等待的阻塞方法
        if ( !internalLock(-1, null) )
        {
            throw new IOException("Lost connection while trying to acquire lock: " + basePath);
        }
}

   internalLock

private boolean internalLock(long time, TimeUnit unit) throws Exception
    {
        //得到当前线程
        Thread currentThread = Thread.currentThread();
        //使用threadData存储线程重入的情况
        LockData lockData = threadData.get(currentThread);
        if ( lockData != null )
        {
            // re-entering
            //同一线程再次acquire,首先判断当前的映射表内(threadData)是否有该线程的锁信息,如果有则原子+1,然后返回
            lockData.lockCount.incrementAndGet();
            return true;
        }
        // 映射表内没有对应的锁信息,尝试通过LockInternals获取锁
        String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
        if ( lockPath != null )
        {
            // 成功获取锁,记录信息到映射表
            LockData newLockData = new LockData(currentThread, lockPath);
            threadData.put(currentThread, newLockData);
            return true;
        }
        return false;
}
// 映射表
// 记录线程与锁信息的映射关系
private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();
// 锁信息
// Zookeeper中一个临时顺序节点对应一个“锁”,但让锁生效激活需要排队(公平锁),下面会继续分析
private static class LockData{
  final Thread owningThread;
  final String lockPath;
  final AtomicInteger lockCount = new AtomicInteger(1); // 分布式锁重入次数
  private LockData(Thread owningThread, String lockPath){
    this.owningThread = owningThread;
    this.lockPath = lockPath;
  }
}

  attemptLock :尝试获得锁,实际上是向zookeeper注册一个临时有序节点,并且判断当前创建的节点的顺序是否是最 小节点。如果是则表示获得锁成功

// 尝试获取锁,并返回锁对应的Zookeeper临时顺序节点的路径
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
    {
        final long      startMillis = System.currentTimeMillis();
        // 无限等待时,millisToWait为null
        final Long      millisToWait = (unit != null) ? unit.toMillis(time) : null;
        // 创建ZNode节点时的数据内容,无关紧要,这里为null
        final byte[]    localLockNodeBytes = (revocable.get() != null) ? new byte[0] : lockNodeBytes;
        // 当前已经重试次数,与CuratorFramework的重试策略有关
        int             retryCount = 0;
        // 在Zookeeper中创建的临时顺序节点的路径,相当于一把待激活的分布式锁
        // 激活条件:同级目录子节点,名称排序最小(排队,公平锁),后续继续分析
        String          ourPath = null;
        boolean         hasTheLock = false;
        // 是否已经完成尝试获取分布式锁的操作
        boolean         isDone = false;
        while ( !isDone )
        {
            isDone = true;
            try
            {
                // 在Zookeeper中创建临时顺序节点
                ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
                // 循环等待来激活分布式锁,实现锁的公平性,后续继续分析
                hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
            }
            catch ( KeeperException.NoNodeException e )
            {
                // 省略。。。。。
            }
        }
        if ( hasTheLock )
        {
            // 成功获得分布式锁,返回临时顺序节点的路径,上层将其封装成锁信息记录在映射表,方便锁重入
            return ourPath;
        }

        return null;
    }

  createsTheLock:在Zookeeper中创建临时顺序节点

@Override
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception
    {
        String ourPath;
        // lockNodeBytes不为null则作为数据节点内容,否则采用默认内容(IP地址)
        if ( lockNodeBytes != null )
        {// creatingParentContainersIfNeeded:用于创建容器节点
          // withProtection:临时子节点会添加GUID前缀
            ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, lockNodeBytes);
        }
        else
        {// CreateMode.EPHEMERAL_SEQUENTIAL:临时顺序节点,Zookeeper能保证在节点产生的顺序性
          // 依据顺序来激活分布式锁,从而也实现了分布式锁的公平性,后续继续分析
            ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path);
        }
        return ourPath;
    }

   internalLockLoop :

// 循环等待来激活分布式锁,实现锁的公平性
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
    {
        boolean     haveTheLock = false; // 是否已经持有分布式锁
        boolean     doDelete = false;// 是否需要删除子节点
        try
        {
            if ( revocable.get() != null )
            {
                client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
            }
            //在没有获得锁的情况下持续循环
            while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock )
            { // 获取排序后的子节点列表
                List<String>        children = getSortedChildren();
                // 获取前面自己创建的临时顺序子节点的名称
                String              sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
                // 实现锁的公平性的核心逻辑,看下面的分析
                PredicateResults    predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
                if ( predicateResults.getsTheLock() )
                {// 获得了锁,中断循环,继续返回上层
                    haveTheLock = true;
                }
                else
                {// 没有获得到锁,监听上一临时顺序节点
                    String  previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();

                    synchronized(this)
                    {
                        try
                        {// exists()会导致导致资源泄漏,因此exists()可以监听不存在的ZNode,因此采用getData()
                         // 上一临时顺序节点如果被删除,会唤醒当前线程继续竞争锁,正常情况下能直接获得锁,因为锁是公平的
                            // use getData() instead of exists() to avoid leaving unneeded watchers which is a type of resource leak
                            client.getData().usingWatcher(watcher).forPath(previousSequencePath);
                            if ( millisToWait != null )
                            {//是否有超时机制
                                millisToWait -= (System.currentTimeMillis() - startMillis);
                                startMillis = System.currentTimeMillis();
                                if ( millisToWait <= 0 )
                                {// 获取锁超时,标记删除之前创建的临时顺序节点
                                    doDelete = true;    // timed out - delete our node
                                    break;
                                }

                                wait(millisToWait);//限时等待被唤醒
                            }
                            else
                            {
                                wait(); //不限时等待
                            }
                        }
                        catch ( KeeperException.NoNodeException e )
                        {// 容错处理,逻辑稍微有点绕,可跳过,不影响主逻辑的理解
                         // client.getData()可能调用时抛出NoNodeException,原因可能是锁被释放或会话过期(连接丢失)等
                         // 这里并没有做任何处理,因为外层是while循环,再次执行driver.getsTheLock时会调用validateOurIndex
               // 此时会抛出NoNodeException,从而进入下面的catch和finally逻辑,重新抛出上层尝试重试获取锁并删除临时顺序节
                         // it has been deleted (i.e. lock released). Try to acquire again
                        }
                    }
                }
            }
        }
        catch ( Exception e )
        {
            ThreadUtils.checkInterrupted(e);
            doDelete = true; // 标记删除,在finally删除之前创建的临时顺序节点(后台不断尝试)
            throw e;
        }
        finally
        {
            if ( doDelete )
            {
                deleteOurPath(ourPath);//删除当前节点
            }
        }
        return haveTheLock;
    }

  StandardLockInternalsDriver#getsTheLock :

@Override
public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception
    {// 之前创建的临时顺序节点在排序后的子节点列表中的索引
        int             ourIndex = children.indexOf(sequenceNodeName);
        validateOurIndex(sequenceNodeName, ourIndex);
        // 锁公平性的核心逻辑
    // 由InterProcessMutex的构造函数可知,maxLeases为1,即只有ourIndex为0时,线程才能持有锁,或者说该线程创建的临时顺序节点激活了锁
    // Zookeeper的临时顺序节点特性能保证跨多个JVM的线程并发创建节点时的顺序性,越早创建临时顺序节点成功的线程会更早地激活锁或获得锁
        boolean         getsTheLock = ourIndex < maxLeases;
    // 如果已经获得了锁,则无需监听任何节点,否则需要监听上一顺序节点(ourIndex-1)
    // 因为锁是公平的,因此无需监听除了(ourIndex-1)以外的所有节点,这是为了减少羊群效应,非常巧妙的设计!!
        String          pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases);
    // 返回获取锁的结果,交由上层继续处理(添加监听等操作)
        return new PredicateResults(pathToWatch, getsTheLock);
}

释放锁的逻辑 release:

public void release() throws Exception
    {
        /*
            Note on concurrency: a given lockData instance
            can be only acted on by a single thread so locking isn't necessary
         */

        Thread currentThread = Thread.currentThread();
        LockData lockData = threadData.get(currentThread);
        if ( lockData == null )
        {// 无法从映射表中获取锁信息,表示当前没有持有锁
            throw new IllegalMonitorStateException("You do not own the lock: " + basePath);
        }
        // 锁是可重入的,初始值为1,原子-1到0,锁才释放
        int newLockCount = lockData.lockCount.decrementAndGet();
        if ( newLockCount > 0 )
        {
            return;
        }
        if ( newLockCount < 0 )
        {
            throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath);
        }
        try
        {// 锁是可重入的,初始值为1,原子-1到0,锁才释放
            internals.releaseLock(lockData.lockPath);
        }
        finally
        {// 最后从映射表中移除当前线程的锁信息
            threadData.remove(currentThread);
        }
    }

   releaseLock :

final void releaseLock(String lockPath) throws Exception
{
        client.removeWatchers();//移除订阅事件
        revocable.set(null);
        // 删除临时顺序节点,只会触发后一顺序节点去获取锁,理论上不存在竞争,只排队,非抢占,公平锁,先到先得
        deleteOurPath(lockPath);
}

 锁撤销:

  InterProcessMutex支持一种协商撤销互斥锁的机制, 可以用于死锁的情况 想要撤销一个互斥锁可以调用下面这个方法:

public void makeRevocable(RevocationListener<T> listener)

  这个方法可以让锁持有者来处理撤销动作。 当其他进程/线程想要你释放锁时,就会回调参数中的监听 器方法。 但是,此方法不是强制撤销的,是一种协商机制。

  当想要去撤销/释放一个锁时,可以通过 Revoker 中的静态方法来发出请求, Revoker.attemptRevoke();

public static void attemptRevoke(CuratorFramework client,String path) throws Exception

  path :加锁的zk节点path,

  通常可以通过 InterProcessMutex.getParticipantNodes() 获得 这个方法会发出撤销某个锁的请求。如果锁的持有者注册了上述的 RevocationListener 监听器,那么 就会调用监听器方法协商撤销锁。

 

posted @ 2018-11-21 17:37  吴振照  阅读(1482)  评论(0编辑  收藏  举报